From ea082e20164870240684b7ef697af3e7fb750436 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sat, 7 Mar 2026 19:58:43 +0000 Subject: [PATCH] Changeset 0.24.2 (#158) --- CHANGELOG.md | 22 ++ VERSION | 2 +- docs/DESIGN-0.24.0.md | 72 +++++-- docs/ROADMAP.md | 18 +- docs/{ => archive}/DESIGN-0.20.0.md | 0 docs/{ => archive}/DESIGN-0.21.0.md | 0 server/auth/oidc.go | 2 +- server/auth/permissions.go | 164 +++++++++++++++ .../database/migrations/020_permissions.sql | 36 ++++ .../migrations/sqlite/020_permissions.sql | 25 +++ server/handlers/capabilities.go | 18 ++ server/handlers/completion.go | 39 ++++ server/handlers/groups.go | 74 ++++++- server/main.go | 18 +- server/middleware/permissions.go | 60 ++++++ server/models/models.go | 31 ++- server/store/interfaces.go | 2 +- server/store/postgres/groups.go | 191 ++++++++++++++---- server/store/postgres/helpers.go | 7 + server/store/sqlite/groups.go | 162 ++++++++++++--- server/store/sqlite/helpers.go | 5 + src/js/admin-scaffold.js | 37 ++++ src/js/api.js | 4 + src/js/ui-admin.js | 84 +++++++- 24 files changed, 954 insertions(+), 119 deletions(-) rename docs/{ => archive}/DESIGN-0.20.0.md (100%) rename docs/{ => archive}/DESIGN-0.21.0.md (100%) create mode 100644 server/auth/permissions.go create mode 100644 server/database/migrations/020_permissions.sql create mode 100644 server/database/migrations/sqlite/020_permissions.sql create mode 100644 server/middleware/permissions.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac3353..46b2c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to Chat Switchboard. +## [0.24.2] — 2026-03-07 + +### Added +- **Fine-grained permission system.** 12 permission constants using `domain.action` convention (`model.use`, `model.select_any`, `kb.read`, `kb.write`, `kb.create`, `channel.create`, `channel.invite`, `persona.create`, `persona.manage`, `workflow.create`, `admin.view`, `token.unlimited`). `AllPermissions` slice for UI rendering and validation. +- **Permission resolution.** `auth.ResolvePermissions()` computes effective permissions as the union of the Everyone group plus all explicitly assigned group memberships. Admin users bypass checks entirely. +- **`RequirePermission()` middleware.** Gin middleware with per-request permission caching — computed at most once regardless of how many permission gates are chained. `GetResolvedPermissions()` helper for conditional logic in handlers. +- **Permission-gated routes.** `POST /personas` requires `persona.create`. `PUT/DELETE /personas/:id` requires `persona.manage`. `POST /knowledge-bases` requires `kb.create`. `POST /knowledge-bases/:id/documents` requires `kb.write`. `POST /channels` requires `channel.create`. `POST /channels/:id/participants` requires `channel.invite`. +- **Token budgets.** Per-group daily and monthly token ceilings stored as BIGINT columns on groups. `auth.ResolveTokenBudget()` returns the most restrictive budget across all memberships. Pre-flight enforcement in the completion handler — queries `usage_log` for current period totals, returns 429 when exceeded. BYOK bypass: personal-scope providers skip budget checks entirely. +- **Model access control.** Per-group `allowed_models` JSONB column (NULL = unrestricted). `auth.ResolveModelAllowlist()` unions all group allowlists — any group with NULL means unrestricted. Enforced in both the completion handler (403 on disallowed model) and the model list endpoint (filters response so users only see permitted models). +- **Everyone group.** Implicit group with stable well-known ID (`00000000-...0001`), `source=system`, seeded in migration 020. All authenticated users receive its permissions without explicit membership. Editable in admin — replaces the previously planned `DefaultUserPerms` / `global_config` approach. `source=system` guard prevents deletion (returns 400 from the store layer). +- **Admin permissions endpoints.** `GET /api/v1/admin/permissions` returns all valid permission strings. `GET /api/v1/admin/users/:id/permissions` returns effective resolved permissions for a user. +- **Admin group permissions UI.** Group detail view expanded with three new sections: permission checklist (checkbox per permission with description), token budget fields (daily/monthly, empty = unlimited), and model allowlist multi-select (deduped by model_id, all-checked = unrestricted). Single save button sends all fields in one PUT. +- **API methods.** `adminListPermissions()`, `adminGetUserPermissions(userId)`. + +### Fixed +- **OIDC group creation compile error.** `auth/oidc.go` line 412 passed `string` to `*string` field (`Group.CreatedBy`). Fixed to `&createdBy`. + +### Migration Notes +- **Migration 020:** Adds `permissions` (JSONB, default `'[]'`), `token_budget_daily` (BIGINT), `token_budget_monthly` (BIGINT), `allowed_models` (JSONB) columns to `groups` table. Extends `groups.source` CHECK to include `system`. Drops `NOT NULL` on `created_by` (NULL for system-seeded groups). Seeds the Everyone group with `model.use`, `kb.read`, `channel.create` permissions. +- Both Postgres and SQLite migrations included. +- **No new env vars.** + ## [0.24.1] — 2026-03-07 ### Added diff --git a/VERSION b/VERSION index 48b91fd..8b95abd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.1 +0.24.2 diff --git a/docs/DESIGN-0.24.0.md b/docs/DESIGN-0.24.0.md index bd2e504..27bfcfe 100644 --- a/docs/DESIGN-0.24.0.md +++ b/docs/DESIGN-0.24.0.md @@ -681,31 +681,52 @@ COMMENT ON COLUMN groups.permissions IS 'Array of permission strings granted to COMMENT ON COLUMN groups.allowed_models IS 'Array of model_id strings this group can use. NULL = unrestricted.'; ``` +### The Everyone Group + +Rather than hardcoding base permissions in application code, the system +seeds a special **Everyone** group at migration time. Every authenticated +user implicitly receives its permissions without explicit membership. + +Properties: +- `id` = `00000000-0000-0000-0000-000000000001` (stable, well-known) +- `name` = `"Everyone"` +- `scope` = `"global"` +- `source` = `"system"` (guards against deletion) + +The Everyone group is editable in the admin UI — an org that wants +locked-down defaults empties it; an open install leaves it permissive. +`DefaultUserPerms` via `global_config` (previously in ROADMAP) is +superseded by this approach: it was a one-time init value anyway. +The migration seeds the group; subsequent changes are live data. + +`Delete` on a `source=system` group returns `400 Bad Request`. The guard +lives in the store layer, not the handler, so it can't be bypassed. + ### Permission Resolution -A user's effective permissions are the **union** of all their groups' -permissions, plus base permissions that every active user gets. +A user's effective permissions are the **union** of the Everyone group's +permissions plus all their explicitly assigned groups' permissions. ```go // server/auth/permissions.go -// Base permissions granted to all active users (no group needed) -var basePermissions = map[string]bool{ - "model.use": true, - "kb.read": true, - "channel.create": true, -} +const EveryoneGroupID = "00000000-0000-0000-0000-000000000001" // ResolvePermissions returns the effective permission set for a user. +// Always includes the Everyone group regardless of membership. +// Admin users bypass this entirely — callers should check role first. func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) { perms := make(map[string]bool) - // Start with base permissions - for k, v := range basePermissions { - perms[k] = v + // Always apply Everyone group + everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID) + if err == nil { + for _, p := range everyone.Permissions { + perms[p] = true + } } - // Union all group permissions + // Union all explicit group memberships groups, err := stores.Groups.ListForUser(ctx, userID) if err != nil { return perms, err @@ -723,6 +744,21 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) Admin users (`role=admin`) bypass permission checks entirely, same as they bypass team membership checks today. +### BYOK Budget Bypass + +Token budget enforcement only applies when the completion draws from a +**team or global provider**. If `providerScope == "personal"` the request +is the user's own money — budget pre-flight is skipped entirely. The +`providerScope` value is already resolved by `resolveConfig()` before +the completion handler begins streaming. + +### Anonymous Sessions + +v0.24.3 session participants carry `SessionClaims`, not `UserClaims`. +They never enter group-based permission resolution. Their capability is +bounded to their channel and the persona bound to it by the workflow +definition. Do not conflate the two paths. + ### Middleware Helper ```go @@ -813,9 +849,15 @@ Group edit form gains: ### What Ships -- Migration 019 (group permissions, token budgets, model allowlist) -- `server/auth/permissions.go` (resolution logic) -- `middleware/permissions.go` (`RequirePermission()`) +- Migration 020 (group permissions, token budgets, model allowlist, Everyone group seed) +- `server/auth/permissions.go` (constants, resolution logic, budget resolution) +- `middleware/permissions.go` (`RequirePermission()` with request-context cache) +- Permission-gated handler routes +- Token budget pre-flight in completion handler (skipped for personal/BYOK providers) +- Model access filtering in model list endpoint +- Admin UI: group permissions editor, user effective permissions view +- Both Postgres and SQLite store implementations +- Everyone group: seeded in migration, guarded from deletion in store layer - Permission-gated handler routes - Token budget pre-flight in completion handler - Model access filtering in model list endpoint diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f05e518..da8bcc6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1430,15 +1430,15 @@ group infrastructure. Depends on: auth abstraction (v0.24.0). Parallel to v0.24.1. See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.2 for full spec. -- [ ] Permission constants (`domain.action` convention, code-level enum) -- [ ] `permissions` JSONB column on groups -- [ ] `ResolvePermissions()`: union of group permissions + `DefaultUserPerms` -- [ ] `RequirePermission()` middleware -- [ ] Token budgets: per-group daily/monthly ceilings, enforced in completion handler -- [ ] Model access control: per-group model allowlists -- [ ] Admin UI: permission checklist, budget fields, model picker on group edit -- [ ] `DefaultUserPerms` override via `global_config` key `default_permissions` -- [ ] Migration 020: permissions, budgets, allowed_models on groups +- [x] Permission constants (`domain.action` convention, code-level enum) +- [x] `permissions` JSONB column on groups +- [x] `ResolvePermissions()`: union of group permissions + Everyone group +- [x] `RequirePermission()` middleware +- [x] Token budgets: per-group daily/monthly ceilings, enforced in completion handler +- [x] Model access control: per-group model allowlists +- [x] Admin UI: permission checklist, budget fields, model picker on group edit +- [x] Everyone group supersedes `DefaultUserPerms` (seeded in migration, editable in admin) +- [x] Migration 020: permissions, budgets, allowed_models on groups --- diff --git a/docs/DESIGN-0.20.0.md b/docs/archive/DESIGN-0.20.0.md similarity index 100% rename from docs/DESIGN-0.20.0.md rename to docs/archive/DESIGN-0.20.0.md diff --git a/docs/DESIGN-0.21.0.md b/docs/archive/DESIGN-0.21.0.md similarity index 100% rename from docs/DESIGN-0.21.0.md rename to docs/archive/DESIGN-0.21.0.md diff --git a/server/auth/oidc.go b/server/auth/oidc.go index 5c7f50d..d661124 100644 --- a/server/auth/oidc.go +++ b/server/auth/oidc.go @@ -409,7 +409,7 @@ func (p *OIDCProvider) findOrCreateOIDCGroup(ctx context.Context, name, createdB Description: "Synced from OIDC", Scope: "global", Source: "oidc", - CreatedBy: createdBy, + CreatedBy: &createdBy, } if err := stores.Groups.Create(ctx, g); err != nil { log.Printf("[auth/oidc] warn: could not create group %s: %v", name, err) diff --git a/server/auth/permissions.go b/server/auth/permissions.go new file mode 100644 index 0000000..0747116 --- /dev/null +++ b/server/auth/permissions.go @@ -0,0 +1,164 @@ +package auth + +import ( + "context" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in +// migration 020. Every authenticated user receives its permissions without an +// explicit membership row. +const EveryoneGroupID = "00000000-0000-0000-0000-000000000001" + +// Permission constants — domain.action convention. +const ( + PermModelUse = "model.use" // use models for completion + PermModelSelectAny = "model.select_any" // use any enabled model (vs. group allowlist) + PermKBRead = "kb.read" // search KBs through personas + PermKBWrite = "kb.write" // upload/delete KB documents + PermKBCreate = "kb.create" // create new knowledge bases + PermChannelCreate = "channel.create" // create group/channel conversations + PermChannelInvite = "channel.invite" // invite users to channels + PermPersonaCreate = "persona.create" // create new personas + PermPersonaManage = "persona.manage" // edit/delete team personas + PermWorkflowCreate = "workflow.create" // create workflow definitions (v0.25.0) + PermAdminView = "admin.view" // read-only admin panel access + PermTokenUnlimited = "token.unlimited" // bypass token budgets +) + +// AllPermissions is the complete set of valid permission strings. +// Used for validation in handlers and rendering checkboxes in admin UI. +var AllPermissions = []string{ + PermModelUse, + PermModelSelectAny, + PermKBRead, + PermKBWrite, + PermKBCreate, + PermChannelCreate, + PermChannelInvite, + PermPersonaCreate, + PermPersonaManage, + PermWorkflowCreate, + PermAdminView, + PermTokenUnlimited, +} + +// ── Resolution ────────────────────────────── + +// ResolvePermissions returns the effective permission set for a user. +// Always includes the Everyone group regardless of membership. +// Callers should short-circuit for role=admin before calling this. +func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) { + perms := make(map[string]bool) + + // Always apply Everyone group — no membership row required. + if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil { + for _, p := range everyone.Permissions { + perms[p] = true + } + } + + // Union all explicit group memberships. + groups, err := stores.Groups.ListForUser(ctx, userID) + if err != nil { + return perms, err + } + for _, g := range groups { + for _, p := range g.Permissions { + perms[p] = true + } + } + + return perms, nil +} + +// ── Token Budget ──────────────────────────── + +// TokenBudget holds the resolved ceiling for a user and the time window it covers. +type TokenBudget struct { + Limit int64 + Period string // "daily" or "monthly" +} + +// ResolveTokenBudget returns the most restrictive token budget across all of the +// user's groups. Returns nil if no group has a budget set (unrestricted). +// Callers should skip budget enforcement when providerScope == "personal" (BYOK). +func ResolveTokenBudget(ctx context.Context, stores store.Stores, userID string) (*TokenBudget, error) { + groups, err := stores.Groups.ListForUser(ctx, userID) + if err != nil { + return nil, err + } + + now := time.Now() + isNewDay := now.Hour() < 1 + _ = isNewDay // used by callers for cache invalidation hints if needed + + var daily, monthly *int64 + for _, g := range groups { + if g.TokenBudgetDaily != nil { + if daily == nil || *g.TokenBudgetDaily < *daily { + daily = g.TokenBudgetDaily + } + } + if g.TokenBudgetMonthly != nil { + if monthly == nil || *g.TokenBudgetMonthly < *monthly { + monthly = g.TokenBudgetMonthly + } + } + } + + // Return the most restrictive window. Daily wins when both are set + // and daily is the binding constraint. + if daily != nil { + return &TokenBudget{Limit: *daily, Period: "daily"}, nil + } + if monthly != nil { + return &TokenBudget{Limit: *monthly, Period: "monthly"}, nil + } + return nil, nil +} + +// ── Model Allowlist ───────────────────────── + +// ResolveModelAllowlist returns the union of allowed model IDs across all of the +// user's groups. Returns nil if the user is unrestricted (any group has a nil +// allowlist, which means "no restriction"). +func ResolveModelAllowlist(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) { + groups, err := stores.Groups.ListForUser(ctx, userID) + if err != nil { + return nil, err + } + + // If any group has nil AllowedModels, the user is unrestricted. + for _, g := range groups { + if g.AllowedModels == nil { + return nil, nil + } + } + + // All groups have explicit allowlists — union them. + allowed := make(map[string]bool) + for _, g := range groups { + for _, m := range g.AllowedModels { + allowed[m] = true + } + } + // No groups at all → fall through to Everyone group check. + if len(groups) == 0 { + if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil { + if everyone.AllowedModels == nil { + return nil, nil // Everyone has no restriction + } + for _, m := range everyone.AllowedModels { + allowed[m] = true + } + } + } + + if len(allowed) == 0 { + return nil, nil // empty allowlists across all groups = unrestricted + } + return allowed, nil +} diff --git a/server/database/migrations/020_permissions.sql b/server/database/migrations/020_permissions.sql new file mode 100644 index 0000000..a30b7bf --- /dev/null +++ b/server/database/migrations/020_permissions.sql @@ -0,0 +1,36 @@ +-- 020_permissions.sql +-- Fine-grained permissions on groups (v0.24.2) + +-- Extend the groups.source CHECK to include 'system' (system-seeded groups). +ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_source_check; +ALTER TABLE groups ADD CONSTRAINT groups_source_check + CHECK (source IN ('manual', 'oidc', 'system')); + +-- created_by is meaningless for system-seeded groups; allow NULL. +ALTER TABLE groups ALTER COLUMN created_by DROP NOT NULL; + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS token_budget_daily BIGINT, + ADD COLUMN IF NOT EXISTS token_budget_monthly BIGINT, + ADD COLUMN IF NOT EXISTS allowed_models JSONB; + +COMMENT ON COLUMN groups.permissions IS 'Array of permission strings granted to group members'; +COMMENT ON COLUMN groups.token_budget_daily IS 'Daily token ceiling for members (NULL = unlimited)'; +COMMENT ON COLUMN groups.token_budget_monthly IS 'Monthly token ceiling for members (NULL = unlimited)'; +COMMENT ON COLUMN groups.allowed_models IS 'Array of model_id strings this group may use. NULL = unrestricted.'; + +-- Seed the Everyone group with a stable well-known ID. +-- All authenticated users receive its permissions implicitly (no membership row needed). +-- source='system' prevents deletion via the store guard. +INSERT INTO groups (id, name, description, scope, created_by, source, permissions) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'Everyone', + 'Implicit group — all authenticated users receive these permissions.', + 'global', + NULL, + 'system', + '["model.use","kb.read","channel.create"]'::jsonb +) +ON CONFLICT (id) DO NOTHING; diff --git a/server/database/migrations/sqlite/020_permissions.sql b/server/database/migrations/sqlite/020_permissions.sql new file mode 100644 index 0000000..b00e583 --- /dev/null +++ b/server/database/migrations/sqlite/020_permissions.sql @@ -0,0 +1,25 @@ +-- Chat Switchboard — 020 Permissions (SQLite) (v0.24.2) + +-- SQLite cannot drop NOT NULL inline; recreating the constraint isn't needed +-- since SQLite doesn't enforce CHECK constraints strictly and the column +-- already accepts NULL in practice. The store layer guards source='system'. + +ALTER TABLE groups ADD COLUMN permissions TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE groups ADD COLUMN token_budget_daily INTEGER; +ALTER TABLE groups ADD COLUMN token_budget_monthly INTEGER; +ALTER TABLE groups ADD COLUMN allowed_models TEXT; + +-- Seed the Everyone group with a stable well-known ID. +INSERT OR IGNORE INTO groups + (id, name, description, scope, created_by, source, permissions, created_at, updated_at) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'Everyone', + 'Implicit group — all authenticated users receive these permissions.', + 'global', + NULL, + 'system', + '["model.use","kb.read","channel.create"]', + datetime('now'), + datetime('now') +); diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go index 2b64dd9..04e5ab3 100644 --- a/server/handlers/capabilities.go +++ b/server/handlers/capabilities.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" + "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -56,6 +57,23 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) { // Include admin default model so frontend can use it in resolution chain defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model") + // Model allowlist filtering (v0.24.2) — non-admin users with group-level + // model restrictions only see permitted models. Persona entries whose + // underlying model is disallowed are also filtered. + role, _ := c.Get("role") + if role != "admin" { + allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID) + if err == nil && allowlist != nil { + filtered := make([]models.UserModel, 0, len(userModels)) + for _, m := range userModels { + if allowlist[m.ModelID] { + filtered = append(filtered, m) + } + } + userModels = filtered + } + } + c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel}) } diff --git a/server/handlers/completion.go b/server/handlers/completion.go index ba5c1f7..ca7b0a3 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" + "git.gobha.me/xcaliber/chat-switchboard/auth" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" @@ -625,6 +626,44 @@ func (h *CompletionHandler) Complete(c *gin.Context) { provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID) } + // ── Permission pre-flight ───────────────────────────────────────────── + // Budget enforcement and model allowlist only apply when the request draws + // from a team or global provider. BYOK (personal scope) is the user's money + // — no ceiling applies. + role, _ := c.Get("role") + if role != "admin" && providerScope != "personal" { + // Token budget + if h.stores.Usage != nil { + budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID) + if err == nil && budget != nil { + var opts store.UsageQueryOptions + now := time.Now() + if budget.Period == "daily" { + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + opts.Since = &dayStart + } else { + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + opts.Since = &monthStart + } + totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts) + if err == nil && totals != nil { + used := int64(totals.InputTokens + totals.OutputTokens) + if used >= budget.Limit { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "token budget exceeded"}) + return + } + } + } + } + + // Model allowlist + allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID) + if err == nil && allowlist != nil && !allowlist[model] { + c.JSON(http.StatusForbidden, gin.H{"error": "model not permitted for your account: " + model}) + return + } + } + // Determine streaming stream := true // default if req.Stream != nil { diff --git a/server/handlers/groups.go b/server/handlers/groups.go index 44e9207..316172e 100644 --- a/server/handlers/groups.go +++ b/server/handlers/groups.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/notifications" @@ -22,8 +23,15 @@ type createGroupRequest struct { } type updateGroupRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` + 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 { @@ -85,7 +93,7 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) { Description: req.Description, Scope: req.Scope, TeamID: req.TeamID, - CreatedBy: actorID, + CreatedBy: &actorID, } if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil { @@ -132,12 +140,34 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) { return } - if req.Name == nil && req.Description == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) - 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, } - err := h.stores.Groups.Update(c.Request.Context(), id, req.Name, req.Description) + // 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 @@ -166,6 +196,11 @@ func (h *GroupHandler) DeleteGroup(c *gin.Context) { return } if err != nil { + // Store layer returns a plain error for system groups. + if err.Error() == "system groups cannot be deleted" { + c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } @@ -382,3 +417,28 @@ func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) AuditLog(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") + + perms, err := auth.ResolvePermissions(c.Request.Context(), 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) + } + c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID}) +} diff --git a/server/main.go b/server/main.go index f30eab6..770c019 100644 --- a/server/main.go +++ b/server/main.go @@ -419,7 +419,7 @@ func main() { // Channels channels := handlers.NewChannelHandler() protected.GET("/channels", channels.ListChannels) - protected.POST("/channels", channels.CreateChannel) + protected.POST("/channels", middleware.RequirePermission(auth.PermChannelCreate, stores), channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) protected.PUT("/channels/:id", channels.UpdateChannel) protected.DELETE("/channels/:id", channels.DeleteChannel) @@ -498,7 +498,7 @@ func main() { // Channel participants (v0.23.0 — ICD §3.7) partH := handlers.NewParticipantHandler(stores) protected.GET("/channels/:id/participants", partH.List) - protected.POST("/channels/:id/participants", partH.Add) + protected.POST("/channels/:id/participants", middleware.RequirePermission(auth.PermChannelInvite, stores), partH.Add) protected.PATCH("/channels/:id/participants/:participantId", partH.Update) protected.DELETE("/channels/:id/participants/:participantId", partH.Remove) @@ -573,9 +573,9 @@ func main() { // Personas personas := handlers.NewPersonaHandler(stores) protected.GET("/personas", personas.ListUserPersonas) - protected.POST("/personas", personas.CreateUserPersona) - protected.PUT("/personas/:id", personas.UpdateUserPersona) - protected.DELETE("/personas/:id", personas.DeleteUserPersona) + protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona) + protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona) + protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona) protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar) protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar) protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 @@ -692,12 +692,12 @@ func main() { // Knowledge Bases (RAG — v0.14.0) kbH := handlers.NewKnowledgeBaseHandler(stores, objStore, kbIngester, kbEmbedder) - protected.POST("/knowledge-bases", kbH.CreateKB) + protected.POST("/knowledge-bases", middleware.RequirePermission(auth.PermKBCreate, stores), kbH.CreateKB) protected.GET("/knowledge-bases", kbH.ListKBs) protected.GET("/knowledge-bases/:id", kbH.GetKB) protected.PUT("/knowledge-bases/:id", kbH.UpdateKB) protected.DELETE("/knowledge-bases/:id", kbH.DeleteKB) - protected.POST("/knowledge-bases/:id/documents", kbH.UploadDocument) + protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UploadDocument) protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments) protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus) protected.DELETE("/knowledge-bases/:id/documents/:docId", kbH.DeleteDocument) @@ -859,6 +859,10 @@ func main() { admin.POST("/groups/:id/members", groupAdm.AddMember) admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember) + // Permissions (v0.24.2) + admin.GET("/permissions", groupAdm.ListPermissions) + admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions) + // Resource Grants (admin — v0.16.0) admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant) admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant) diff --git a/server/middleware/permissions.go b/server/middleware/permissions.go new file mode 100644 index 0000000..82bf3a8 --- /dev/null +++ b/server/middleware/permissions.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/auth" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +const permCacheKey = "resolved_permissions" + +// RequirePermission returns middleware that enforces a named permission. +// Admin users bypass the check entirely. Permission resolution is cached in +// the request context — computed at most once per request regardless of how +// many RequirePermission middlewares are chained. +func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get("role") + if role == "admin" { + c.Next() + return + } + + userID := c.GetString("user_id") + perms, err := resolveAndCachePerms(c, stores, userID) + if err != nil || !perms[perm] { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "permission required: " + perm, + }) + return + } + c.Next() + } +} + +// resolveAndCachePerms loads the user's effective permissions once per request. +func resolveAndCachePerms(c *gin.Context, stores store.Stores, userID string) (map[string]bool, error) { + if cached, exists := c.Get(permCacheKey); exists { + return cached.(map[string]bool), nil + } + perms, err := auth.ResolvePermissions(c.Request.Context(), stores, userID) + if err != nil { + return nil, err + } + c.Set(permCacheKey, perms) + return perms, nil +} + +// GetResolvedPermissions returns the cached permission set for the current +// request. Returns nil if not yet resolved (i.e. RequirePermission was not +// earlier in the chain). Callers in handlers can use this for conditional +// logic without triggering an extra DB round-trip. +func GetResolvedPermissions(c *gin.Context) map[string]bool { + if cached, exists := c.Get(permCacheKey); exists { + return cached.(map[string]bool) + } + return nil +} diff --git a/server/models/models.go b/server/models/models.go index a41854f..f360916 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -859,13 +859,30 @@ const ( // Group is an access-control list. Decouples resource visibility from teams. type Group struct { BaseModel - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Scope string `json:"scope" db:"scope"` // global, team - TeamID *string `json:"team_id,omitempty" db:"team_id"` - CreatedBy string `json:"created_by" db:"created_by"` - Source string `json:"source" db:"source"` // manual (default), oidc - MemberCount int `json:"member_count,omitempty"` // computed, not a DB column + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Scope string `json:"scope" db:"scope"` // global, team + TeamID *string `json:"team_id,omitempty" db:"team_id"` + CreatedBy *string `json:"created_by,omitempty" db:"created_by"` + Source string `json:"source" db:"source"` // manual (default), oidc, system + Permissions []string `json:"permissions" db:"permissions"` + TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty" db:"token_budget_daily"` + TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty" db:"token_budget_monthly"` + AllowedModels []string `json:"allowed_models,omitempty" db:"allowed_models"` // nil = unrestricted + MemberCount int `json:"member_count,omitempty"` // computed, not a DB column +} + +// GroupPatch holds optional fields for updating a group. +type GroupPatch 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"` // &[]string{} = restrict to none; nil = no change + ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"` + ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"` + ClearAllowedModels bool `json:"clear_allowed_models,omitempty"` } // GroupMember links a user to a group. diff --git a/server/store/interfaces.go b/server/store/interfaces.go index a48b67d..e96c9a3 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -476,7 +476,7 @@ type KnowledgeBaseStore interface { type GroupStore interface { Create(ctx context.Context, g *models.Group) error GetByID(ctx context.Context, id string) (*models.Group, error) - Update(ctx context.Context, id string, name, description *string) error + Update(ctx context.Context, id string, patch models.GroupPatch) error Delete(ctx context.Context, id string) error // Scoped listing diff --git a/server/store/postgres/groups.go b/server/store/postgres/groups.go index 1dd5adc..fb0120a 100644 --- a/server/store/postgres/groups.go +++ b/server/store/postgres/groups.go @@ -3,6 +3,8 @@ package postgres import ( "context" "database/sql" + "encoding/json" + "errors" "fmt" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -18,41 +20,79 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error { if g.Source == "" { g.Source = "manual" } + if g.Permissions == nil { + g.Permissions = []string{} + } + permsJSON, err := json.Marshal(g.Permissions) + if err != nil { + return fmt.Errorf("marshal permissions: %w", err) + } return DB.QueryRowContext(ctx, ` - INSERT INTO groups (name, description, scope, team_id, created_by, source) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO groups (name, description, scope, team_id, created_by, source, permissions) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at`, g.Name, g.Description, g.Scope, - models.NullString(g.TeamID), g.CreatedBy, g.Source, + models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, permsJSON, ).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt) } func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) { - var g models.Group - var teamID sql.NullString - err := DB.QueryRowContext(ctx, ` + row := DB.QueryRowContext(ctx, ` SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, g.created_at, g.updated_at, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, - COALESCE(g.source, 'manual') - FROM groups g WHERE g.id = $1`, id).Scan( - &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy, - &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source, - ) - if err != nil { - return nil, err - } - g.TeamID = NullableStringPtr(teamID) - return &g, nil + COALESCE(g.source, 'manual'), + COALESCE(g.permissions, '[]'::jsonb), + g.token_budget_daily, + g.token_budget_monthly, + g.allowed_models + FROM groups g WHERE g.id = $1`, id) + return scanGroup(row) } -func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error { - b := NewUpdate("groups") - if name != nil { - b.Set("name", *name) +// Update applies a patch to a group. +// The Everyone group (source=system) allows permission/budget edits but not +// name, description, or structural changes — those fields are silently ignored. +func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error { + var source string + if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil { + return err } - if description != nil { - b.Set("description", *description) + + b := NewUpdate("groups") + if source != "system" { + if patch.Name != nil { + b.Set("name", *patch.Name) + } + if patch.Description != nil { + b.Set("description", *patch.Description) + } + } + if patch.Permissions != nil { + permsJSON, err := json.Marshal(*patch.Permissions) + if err != nil { + return fmt.Errorf("marshal permissions: %w", err) + } + b.Set("permissions", permsJSON) + } + if patch.ClearBudgetDaily { + b.SetNull("token_budget_daily") + } else if patch.TokenBudgetDaily != nil { + b.Set("token_budget_daily", *patch.TokenBudgetDaily) + } + if patch.ClearBudgetMonthly { + b.SetNull("token_budget_monthly") + } else if patch.TokenBudgetMonthly != nil { + b.Set("token_budget_monthly", *patch.TokenBudgetMonthly) + } + if patch.ClearAllowedModels { + b.SetNull("allowed_models") + } else if patch.AllowedModels != nil { + modelsJSON, err := json.Marshal(*patch.AllowedModels) + if err != nil { + return fmt.Errorf("marshal allowed_models: %w", err) + } + b.Set("allowed_models", modelsJSON) } if !b.HasSets() { return nil @@ -68,7 +108,18 @@ func (s *GroupStore) Update(ctx context.Context, id string, name, description *s return nil } +// Delete removes a group. Returns an error if source=system. func (s *GroupStore) Delete(ctx context.Context, id string) error { + var source string + if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sql.ErrNoRows + } + return err + } + if source == "system" { + return fmt.Errorf("system groups cannot be deleted") + } res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id) if err != nil { return err @@ -81,33 +132,31 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error { // ── Scoped Listing ────────────────────────── +const groupSelectCols = ` + g.id, g.name, g.description, g.scope, g.team_id, g.created_by, + g.created_at, g.updated_at, + (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, + COALESCE(g.source, 'manual'), + COALESCE(g.permissions, '[]'::jsonb), + g.token_budget_daily, + g.token_budget_monthly, + g.allowed_models` + func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) { - return queryGroups(ctx, ` - SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, - g.created_at, g.updated_at, - (SELECT COUNT(*) FROM group_members WHERE group_id = g.id), - COALESCE(g.source, 'manual') + return queryGroups(ctx, `SELECT`+groupSelectCols+` FROM groups g ORDER BY g.scope, g.name`) } func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) { - return queryGroups(ctx, ` - SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, - g.created_at, g.updated_at, - (SELECT COUNT(*) FROM group_members WHERE group_id = g.id), - COALESCE(g.source, 'manual') + return queryGroups(ctx, `SELECT`+groupSelectCols+` FROM groups g WHERE g.scope = 'team' AND g.team_id = $1 ORDER BY g.name`, teamID) } func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) { - return queryGroups(ctx, ` - SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, - g.created_at, g.updated_at, - (SELECT COUNT(*) FROM group_members WHERE group_id = g.id), - COALESCE(g.source, 'manual') + return queryGroups(ctx, `SELECT`+groupSelectCols+` FROM groups g WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1) ORDER BY g.scope, g.name`, userID) @@ -191,6 +240,40 @@ func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]stri // ── Scanners ──────────────────────────────── +type groupScanner interface { + Scan(dest ...any) error +} + +func scanGroup(row groupScanner) (*models.Group, error) { + var g models.Group + var teamID sql.NullString + var createdBy sql.NullString + var permsJSON []byte + var budgetDaily, budgetMonthly sql.NullInt64 + var allowedJSON []byte + + if err := row.Scan( + &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, + &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source, + &permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON, + ); err != nil { + return nil, err + } + g.TeamID = NullableStringPtr(teamID) + g.CreatedBy = NullableStringPtr(createdBy) + g.Permissions = unmarshalStringSlice(permsJSON) + if budgetDaily.Valid { + g.TokenBudgetDaily = &budgetDaily.Int64 + } + if budgetMonthly.Valid { + g.TokenBudgetMonthly = &budgetMonthly.Int64 + } + if len(allowedJSON) > 0 && string(allowedJSON) != "null" { + g.AllowedModels = unmarshalStringSlice(allowedJSON) + } + return &g, nil +} + func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) { rows, err := DB.QueryContext(ctx, q, args...) if err != nil { @@ -202,12 +285,42 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G for rows.Next() { var g models.Group var teamID sql.NullString - if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, - &g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source); err != nil { + var createdBy sql.NullString + var permsJSON []byte + var budgetDaily, budgetMonthly sql.NullInt64 + var allowedJSON []byte + + if err := rows.Scan( + &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, + &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source, + &permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON, + ); err != nil { return nil, err } g.TeamID = NullableStringPtr(teamID) + g.CreatedBy = NullableStringPtr(createdBy) + g.Permissions = unmarshalStringSlice(permsJSON) + if budgetDaily.Valid { + g.TokenBudgetDaily = &budgetDaily.Int64 + } + if budgetMonthly.Valid { + g.TokenBudgetMonthly = &budgetMonthly.Int64 + } + if len(allowedJSON) > 0 && string(allowedJSON) != "null" { + g.AllowedModels = unmarshalStringSlice(allowedJSON) + } result = append(result, g) } return result, rows.Err() } + +func unmarshalStringSlice(data []byte) []string { + if len(data) == 0 { + return []string{} + } + var s []string + if err := json.Unmarshal(data, &s); err != nil { + return []string{} + } + return s +} diff --git a/server/store/postgres/helpers.go b/server/store/postgres/helpers.go index 536e87a..a19c65d 100644 --- a/server/store/postgres/helpers.go +++ b/server/store/postgres/helpers.go @@ -44,6 +44,13 @@ func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder { return b } +// SetNull adds a col = NULL pair to the UPDATE. +func (b *UpdateBuilder) SetNull(col string) *UpdateBuilder { + b.argIdx++ + b.sets = append(b.sets, fmt.Sprintf("%s = NULL", col)) + return b +} + // SetJSON adds a JSONB column from a map. func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder { data, err := json.Marshal(val) diff --git a/server/store/sqlite/groups.go b/server/store/sqlite/groups.go index ccb8538..d93620b 100644 --- a/server/store/sqlite/groups.go +++ b/server/store/sqlite/groups.go @@ -3,6 +3,8 @@ package sqlite import ( "context" "database/sql" + "encoding/json" + "errors" "fmt" "time" @@ -24,11 +26,18 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error { if g.Source == "" { g.Source = "manual" } - _, err := DB.ExecContext(ctx, ` - INSERT INTO groups (id, name, description, scope, team_id, created_by, source, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + if g.Permissions == nil { + g.Permissions = []string{} + } + permsJSON, err := json.Marshal(g.Permissions) + if err != nil { + return fmt.Errorf("marshal permissions: %w", err) + } + _, err = DB.ExecContext(ctx, ` + INSERT INTO groups (id, name, description, scope, team_id, created_by, source, permissions, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, g.ID, g.Name, g.Description, g.Scope, - models.NullString(g.TeamID), g.CreatedBy, g.Source, + models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, string(permsJSON), now.Format(timeFmt), now.Format(timeFmt), ) return err @@ -37,29 +46,85 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error { func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) { var g models.Group var teamID sql.NullString + var createdBy sql.NullString + var permsStr sql.NullString + var budgetDaily, budgetMonthly sql.NullInt64 + var allowedStr sql.NullString + err := DB.QueryRowContext(ctx, ` SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, g.created_at, g.updated_at, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, - COALESCE(g.source, 'manual') + COALESCE(g.source, 'manual'), + COALESCE(g.permissions, '[]'), + g.token_budget_daily, + g.token_budget_monthly, + g.allowed_models FROM groups g WHERE g.id = ?`, id).Scan( - &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy, + &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source, + &permsStr, &budgetDaily, &budgetMonthly, &allowedStr, ) if err != nil { return nil, err } g.TeamID = NullableStringPtr(teamID) + g.CreatedBy = NullableStringPtr(createdBy) + g.Permissions = unmarshalStringSlice(permsStr.String) + if budgetDaily.Valid { + g.TokenBudgetDaily = &budgetDaily.Int64 + } + if budgetMonthly.Valid { + g.TokenBudgetMonthly = &budgetMonthly.Int64 + } + if allowedStr.Valid && allowedStr.String != "" { + g.AllowedModels = unmarshalStringSlice(allowedStr.String) + } return &g, nil } -func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error { - b := NewUpdate("groups") - if name != nil { - b.Set("name", *name) +// Update applies a patch to a group. +// system groups allow permission/budget edits but not structural field changes. +func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error { + var source string + if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil { + return err } - if description != nil { - b.Set("description", *description) + + b := NewUpdate("groups") + if source != "system" { + if patch.Name != nil { + b.Set("name", *patch.Name) + } + if patch.Description != nil { + b.Set("description", *patch.Description) + } + } + if patch.Permissions != nil { + permsJSON, err := json.Marshal(*patch.Permissions) + if err != nil { + return fmt.Errorf("marshal permissions: %w", err) + } + b.Set("permissions", string(permsJSON)) + } + if patch.ClearBudgetDaily { + b.SetNull("token_budget_daily") + } else if patch.TokenBudgetDaily != nil { + b.Set("token_budget_daily", *patch.TokenBudgetDaily) + } + if patch.ClearBudgetMonthly { + b.SetNull("token_budget_monthly") + } else if patch.TokenBudgetMonthly != nil { + b.Set("token_budget_monthly", *patch.TokenBudgetMonthly) + } + if patch.ClearAllowedModels { + b.SetNull("allowed_models") + } else if patch.AllowedModels != nil { + modelsJSON, err := json.Marshal(*patch.AllowedModels) + if err != nil { + return fmt.Errorf("marshal allowed_models: %w", err) + } + b.Set("allowed_models", string(modelsJSON)) } if !b.HasSets() { return nil @@ -75,7 +140,18 @@ func (s *GroupStore) Update(ctx context.Context, id string, name, description *s return nil } +// Delete removes a group. Returns an error if source=system. func (s *GroupStore) Delete(ctx context.Context, id string) error { + var source string + if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sql.ErrNoRows + } + return err + } + if source == "system" { + return fmt.Errorf("system groups cannot be deleted") + } res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id) if err != nil { return err @@ -88,33 +164,31 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error { // ── Scoped Listing ────────────────────────── +const groupSelectCols = ` + g.id, g.name, g.description, g.scope, g.team_id, g.created_by, + g.created_at, g.updated_at, + (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, + COALESCE(g.source, 'manual'), + COALESCE(g.permissions, '[]'), + g.token_budget_daily, + g.token_budget_monthly, + g.allowed_models` + func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) { - return queryGroups(ctx, ` - SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, - g.created_at, g.updated_at, - (SELECT COUNT(*) FROM group_members WHERE group_id = g.id), - COALESCE(g.source, 'manual') + return queryGroups(ctx, `SELECT `+groupSelectCols+` FROM groups g ORDER BY g.scope, g.name`) } func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) { - return queryGroups(ctx, ` - SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, - g.created_at, g.updated_at, - (SELECT COUNT(*) FROM group_members WHERE group_id = g.id), - COALESCE(g.source, 'manual') + return queryGroups(ctx, `SELECT `+groupSelectCols+` FROM groups g WHERE g.scope = 'team' AND g.team_id = ? ORDER BY g.name`, teamID) } func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) { - return queryGroups(ctx, ` - SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, - g.created_at, g.updated_at, - (SELECT COUNT(*) FROM group_members WHERE group_id = g.id), - COALESCE(g.source, 'manual') + return queryGroups(ctx, `SELECT `+groupSelectCols+` FROM groups g WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?) ORDER BY g.scope, g.name`, userID) @@ -209,12 +283,42 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G for rows.Next() { var g models.Group var teamID sql.NullString - if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, - &g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source); err != nil { + var createdBy sql.NullString + var permsStr sql.NullString + var budgetDaily, budgetMonthly sql.NullInt64 + var allowedStr sql.NullString + + if err := rows.Scan( + &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, + st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source, + &permsStr, &budgetDaily, &budgetMonthly, &allowedStr, + ); err != nil { return nil, err } g.TeamID = NullableStringPtr(teamID) + g.CreatedBy = NullableStringPtr(createdBy) + g.Permissions = unmarshalStringSlice(permsStr.String) + if budgetDaily.Valid { + g.TokenBudgetDaily = &budgetDaily.Int64 + } + if budgetMonthly.Valid { + g.TokenBudgetMonthly = &budgetMonthly.Int64 + } + if allowedStr.Valid && allowedStr.String != "" { + g.AllowedModels = unmarshalStringSlice(allowedStr.String) + } result = append(result, g) } return result, rows.Err() } + +func unmarshalStringSlice(s string) []string { + if s == "" { + return []string{} + } + var out []string + if err := json.Unmarshal([]byte(s), &out); err != nil { + return []string{} + } + return out +} diff --git a/server/store/sqlite/helpers.go b/server/store/sqlite/helpers.go index b0718c4..49e62a4 100644 --- a/server/store/sqlite/helpers.go +++ b/server/store/sqlite/helpers.go @@ -97,6 +97,11 @@ func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder { return b } +func (b *UpdateBuilder) SetNull(col string) *UpdateBuilder { + b.sets = append(b.sets, col+" = NULL") + return b +} + func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder { data, err := json.Marshal(val) if err != nil { diff --git a/src/js/admin-scaffold.js b/src/js/admin-scaffold.js index 075ffdc..cf3329a 100644 --- a/src/js/admin-scaffold.js +++ b/src/js/admin-scaffold.js @@ -51,6 +51,43 @@ '

' + '' + '' + + + /* ── Permissions (v0.24.2) ── */ + '
' + + '
Permissions
' + + '
' + + '
' + + + /* ── Token Budgets ── */ + '
' + + '
Token Budgets
' + + '
' + + '
' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + + /* ── Model Allowlist ── */ + '
' + + '
Allowed Models
' + + '

Leave empty for unrestricted access to all enabled models.

' + + '
' + + '
' + + + /* ── Save button ── */ + '
' + + '' + + '' + + '
' + + + '
' + + + /* ── Members (existing) ── */ + '
Members
' + '
' + '