All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
445 lines
13 KiB
Go
445 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/auth"
|
|
"switchboard-core/database"
|
|
"switchboard-core/models"
|
|
"switchboard-core/notifications"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// ── Request types ───────────────────────────
|
|
|
|
type createGroupRequest struct {
|
|
Name string `json:"name" binding:"required,min=1,max=200"`
|
|
Description string `json:"description,omitempty"`
|
|
Scope string `json:"scope" binding:"required,oneof=global team"`
|
|
TeamID *string `json:"team_id,omitempty"`
|
|
Permissions []string `json:"permissions,omitempty"`
|
|
}
|
|
|
|
type updateGroupRequest struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
Permissions *[]string `json:"permissions,omitempty"`
|
|
}
|
|
|
|
type addGroupMemberRequest struct {
|
|
UserID string `json:"user_id" binding:"required"`
|
|
}
|
|
|
|
type setResourceGrantRequest struct {
|
|
GrantScope string `json:"grant_scope" binding:"required,oneof=team_only global groups"`
|
|
GrantedGroups []string `json:"granted_groups,omitempty"` // required when grant_scope=groups
|
|
}
|
|
|
|
// ── Handler ─────────────────────────────────
|
|
|
|
type GroupHandler struct {
|
|
stores store.Stores
|
|
}
|
|
|
|
func NewGroupHandler(s store.Stores) *GroupHandler {
|
|
return &GroupHandler{stores: s}
|
|
}
|
|
|
|
// ── Admin: List All Groups ──────────────────
|
|
|
|
func (h *GroupHandler) ListGroups(c *gin.Context) {
|
|
groups, err := h.stores.Groups.ListAll(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
if groups == nil {
|
|
groups = []models.Group{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": groups})
|
|
}
|
|
|
|
// ── Admin: Create Group ─────────────────────
|
|
|
|
func (h *GroupHandler) CreateGroup(c *gin.Context) {
|
|
actorID := getUserID(c)
|
|
|
|
var req createGroupRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate scope constraints
|
|
if req.Scope == models.ScopeTeam && (req.TeamID == nil || *req.TeamID == "") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped groups"})
|
|
return
|
|
}
|
|
if req.Scope == models.ScopeGlobal && req.TeamID != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id must be null for global groups"})
|
|
return
|
|
}
|
|
|
|
g := &models.Group{
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Scope: req.Scope,
|
|
TeamID: req.TeamID,
|
|
CreatedBy: &actorID,
|
|
Permissions: req.Permissions,
|
|
}
|
|
|
|
if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil {
|
|
if database.IsUniqueViolation(err) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, g)
|
|
AuditLog(h.stores.Audit, c, "group.create", "group", g.ID, map[string]interface{}{
|
|
"name": g.Name, "scope": g.Scope,
|
|
})
|
|
}
|
|
|
|
// ── Admin: Get Group ────────────────────────
|
|
|
|
func (h *GroupHandler) GetGroup(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
g, err := h.stores.Groups.GetByID(c.Request.Context(), id)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, g)
|
|
}
|
|
|
|
// ── Admin: Update Group ─────────────────────
|
|
|
|
func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
var req updateGroupRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
patch := models.GroupPatch{
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Permissions: req.Permissions,
|
|
}
|
|
|
|
// Validate permissions if provided
|
|
if patch.Permissions != nil {
|
|
valid := auth.AllPermissions
|
|
validSet := make(map[string]bool, len(valid))
|
|
for _, p := range valid {
|
|
validSet[p] = true
|
|
}
|
|
for _, p := range *patch.Permissions {
|
|
if !validSet[p] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown permission: " + p})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
err := h.stores.Groups.Update(c.Request.Context(), id, patch)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
if database.IsUniqueViolation(err) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
AuditLog(h.stores.Audit, c, "group.update", "group", id, nil)
|
|
}
|
|
|
|
// ── Admin: Delete Group ─────────────────────
|
|
|
|
func (h *GroupHandler) DeleteGroup(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
err := h.stores.Groups.Delete(c.Request.Context(), id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrSystemGroup) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
AuditLog(h.stores.Audit, c, "group.delete", "group", id, nil)
|
|
}
|
|
|
|
// ── Members: List ───────────────────────────
|
|
|
|
func (h *GroupHandler) ListMembers(c *gin.Context) {
|
|
groupID := c.Param("id")
|
|
|
|
members, err := h.stores.Groups.ListMembers(c.Request.Context(), groupID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
if members == nil {
|
|
members = []models.GroupMember{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": members})
|
|
}
|
|
|
|
// ── Members: Add ────────────────────────────
|
|
|
|
func (h *GroupHandler) AddMember(c *gin.Context) {
|
|
groupID := c.Param("id")
|
|
actorID := getUserID(c)
|
|
|
|
var req addGroupMemberRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Verify group exists
|
|
group, err := h.stores.Groups.GetByID(c.Request.Context(), groupID)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up group"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Groups.AddMember(c.Request.Context(), groupID, req.UserID, actorID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
|
|
return
|
|
}
|
|
|
|
// Notify the added user
|
|
if svc := notifications.Default(); svc != nil {
|
|
notifications.NotifyGroupMemberAdded(svc, req.UserID, groupID, group.Name)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
AuditLog(h.stores.Audit, c, "group.member.add", "group", groupID, map[string]interface{}{
|
|
"user_id": req.UserID,
|
|
})
|
|
}
|
|
|
|
// ── Members: Remove ─────────────────────────
|
|
|
|
func (h *GroupHandler) RemoveMember(c *gin.Context) {
|
|
groupID := c.Param("id")
|
|
userID := c.Param("userId")
|
|
|
|
// Fetch group name for notification before removal
|
|
group, _ := h.stores.Groups.GetByID(c.Request.Context(), groupID)
|
|
|
|
err := h.stores.Groups.RemoveMember(c.Request.Context(), groupID, userID)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove member"})
|
|
return
|
|
}
|
|
|
|
// Notify the removed user
|
|
if svc := notifications.Default(); svc != nil && group != nil {
|
|
notifications.NotifyGroupMemberRemoved(svc, userID, groupID, group.Name)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
AuditLog(h.stores.Audit, c, "group.member.remove", "group", groupID, map[string]interface{}{
|
|
"user_id": userID,
|
|
})
|
|
}
|
|
|
|
// ── User: My Groups ─────────────────────────
|
|
|
|
func (h *GroupHandler) MyGroups(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
|
|
groups, err := h.stores.Groups.ListForUser(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
if groups == nil {
|
|
groups = []models.Group{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": groups})
|
|
}
|
|
|
|
// ── Team Admin: List Team Groups ────────────
|
|
|
|
func (h *GroupHandler) ListTeamGroups(c *gin.Context) {
|
|
teamID := getTeamID(c)
|
|
|
|
groups, err := h.stores.Groups.ListForTeam(c.Request.Context(), teamID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
if groups == nil {
|
|
groups = []models.Group{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": groups})
|
|
}
|
|
|
|
// ── Resource Grants ─────────────────────────
|
|
|
|
// GetResourceGrant returns the grant for a resource.
|
|
// GET /api/v1/admin/grants/:type/:id
|
|
func (h *GroupHandler) GetResourceGrant(c *gin.Context) {
|
|
resourceType := c.Param("type")
|
|
resourceID := c.Param("id")
|
|
|
|
grant, err := h.stores.ResourceGrants.Get(c.Request.Context(), resourceType, resourceID)
|
|
if err == sql.ErrNoRows {
|
|
// No grant = default team_only behavior
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"resource_type": resourceType,
|
|
"resource_id": resourceID,
|
|
"grant_scope": models.GrantScopeTeamOnly,
|
|
"granted_groups": []string{},
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, grant)
|
|
}
|
|
|
|
// SetResourceGrant creates or updates a grant for a resource.
|
|
// PUT /api/v1/admin/grants/:type/:id
|
|
func (h *GroupHandler) SetResourceGrant(c *gin.Context) {
|
|
resourceType := c.Param("type")
|
|
resourceID := c.Param("id")
|
|
actorID := getUserID(c)
|
|
|
|
var req setResourceGrantRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate: groups scope requires at least one group
|
|
if req.GrantScope == models.GrantScopeGroups && len(req.GrantedGroups) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "granted_groups required when grant_scope is 'groups'"})
|
|
return
|
|
}
|
|
|
|
// team_only = delete any existing grant (reverts to default scope behavior)
|
|
if req.GrantScope == models.GrantScopeTeamOnly {
|
|
_ = h.stores.ResourceGrants.Delete(c.Request.Context(), resourceType, resourceID)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
AuditLog(h.stores.Audit, c, "grant.revoke", resourceType, resourceID, nil)
|
|
return
|
|
}
|
|
|
|
grant := &models.ResourceGrant{
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
GrantScope: req.GrantScope,
|
|
GrantedGroups: req.GrantedGroups,
|
|
CreatedBy: actorID,
|
|
}
|
|
|
|
if err := h.stores.ResourceGrants.Set(c.Request.Context(), grant); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set grant"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, grant)
|
|
AuditLog(h.stores.Audit, c, "grant.set", resourceType, resourceID, map[string]interface{}{
|
|
"grant_scope": req.GrantScope,
|
|
})
|
|
}
|
|
|
|
// DeleteResourceGrant removes a grant for a resource.
|
|
// DELETE /api/v1/admin/grants/:type/:id
|
|
func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) {
|
|
resourceType := c.Param("type")
|
|
resourceID := c.Param("id")
|
|
|
|
err := h.stores.ResourceGrants.Delete(c.Request.Context(), resourceType, resourceID)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "grant not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
AuditLog(h.stores.Audit, c, "grant.delete", resourceType, resourceID, nil)
|
|
}
|
|
|
|
// ListPermissions returns all valid permission strings.
|
|
// GET /api/v1/admin/permissions
|
|
func (h *GroupHandler) ListPermissions(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"permissions": auth.AllPermissions})
|
|
}
|
|
|
|
// GetUserPermissions returns the effective permissions for a given user.
|
|
// GET /api/v1/admin/users/:id/permissions
|
|
func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
|
|
userID := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
|
|
return
|
|
}
|
|
|
|
// Convert map to sorted slice for stable output
|
|
list := make([]string, 0, len(perms))
|
|
for p := range perms {
|
|
list = append(list, p)
|
|
}
|
|
|
|
// Contributing groups: explicit membership + Everyone
|
|
groupIDs, _ := h.stores.Groups.GetUserGroupIDs(ctx, userID)
|
|
if groupIDs == nil {
|
|
groupIDs = []string{}
|
|
}
|
|
// Everyone group always contributes
|
|
groupIDs = append(groupIDs, auth.EveryoneGroupID)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID, "groups": groupIDs})
|
|
}
|