package handlers import ( "database/sql" "errors" "net/http" "github.com/gin-gonic/gin" "chat-switchboard/auth" "chat-switchboard/database" "chat-switchboard/models" "chat-switchboard/notifications" "chat-switchboard/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"` TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"` TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"` AllowedModels *[]string `json:"allowed_models,omitempty"` ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"` ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"` ClearAllowedModels bool `json:"clear_allowed_models,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, TokenBudgetDaily: req.TokenBudgetDaily, TokenBudgetMonthly: req.TokenBudgetMonthly, AllowedModels: req.AllowedModels, ClearBudgetDaily: req.ClearBudgetDaily, ClearBudgetMonthly: req.ClearBudgetMonthly, ClearAllowedModels: req.ClearAllowedModels, } // 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 (v0.20.0) 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 (v0.20.0) 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}) }