This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/groups.go
2026-03-19 18:50:27 +00:00

455 lines
14 KiB
Go

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"`
}
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,
}
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})
}