Changeset 0.24.2 (#158)

This commit is contained in:
2026-03-07 19:58:43 +00:00
parent b6cc4df6e7
commit ea082e2016
24 changed files with 954 additions and 119 deletions

View File

@@ -2,6 +2,28 @@
All notable changes to Chat Switchboard. 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 ## [0.24.1] — 2026-03-07
### Added ### Added

View File

@@ -1 +1 @@
0.24.1 0.24.2

View File

@@ -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.'; 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 ### Permission Resolution
A user's effective permissions are the **union** of all their groups' A user's effective permissions are the **union** of the Everyone group's
permissions, plus base permissions that every active user gets. permissions plus all their explicitly assigned groups' permissions.
```go ```go
// server/auth/permissions.go // server/auth/permissions.go
// Base permissions granted to all active users (no group needed) const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
var basePermissions = map[string]bool{
"model.use": true,
"kb.read": true,
"channel.create": true,
}
// ResolvePermissions returns the effective permission set for a user. // 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) { func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
perms := make(map[string]bool) perms := make(map[string]bool)
// Start with base permissions // Always apply Everyone group
for k, v := range basePermissions { everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID)
perms[k] = v 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) groups, err := stores.Groups.ListForUser(ctx, userID)
if err != nil { if err != nil {
return perms, err 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 Admin users (`role=admin`) bypass permission checks entirely, same
as they bypass team membership checks today. 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 ### Middleware Helper
```go ```go
@@ -813,9 +849,15 @@ Group edit form gains:
### What Ships ### What Ships
- Migration 019 (group permissions, token budgets, model allowlist) - Migration 020 (group permissions, token budgets, model allowlist, Everyone group seed)
- `server/auth/permissions.go` (resolution logic) - `server/auth/permissions.go` (constants, resolution logic, budget resolution)
- `middleware/permissions.go` (`RequirePermission()`) - `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 - Permission-gated handler routes
- Token budget pre-flight in completion handler - Token budget pre-flight in completion handler
- Model access filtering in model list endpoint - Model access filtering in model list endpoint

View File

@@ -1430,15 +1430,15 @@ group infrastructure.
Depends on: auth abstraction (v0.24.0). Parallel to v0.24.1. 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. 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) - [x] Permission constants (`domain.action` convention, code-level enum)
- [ ] `permissions` JSONB column on groups - [x] `permissions` JSONB column on groups
- [ ] `ResolvePermissions()`: union of group permissions + `DefaultUserPerms` - [x] `ResolvePermissions()`: union of group permissions + Everyone group
- [ ] `RequirePermission()` middleware - [x] `RequirePermission()` middleware
- [ ] Token budgets: per-group daily/monthly ceilings, enforced in completion handler - [x] Token budgets: per-group daily/monthly ceilings, enforced in completion handler
- [ ] Model access control: per-group model allowlists - [x] Model access control: per-group model allowlists
- [ ] Admin UI: permission checklist, budget fields, model picker on group edit - [x] Admin UI: permission checklist, budget fields, model picker on group edit
- [ ] `DefaultUserPerms` override via `global_config` key `default_permissions` - [x] Everyone group supersedes `DefaultUserPerms` (seeded in migration, editable in admin)
- [ ] Migration 020: permissions, budgets, allowed_models on groups - [x] Migration 020: permissions, budgets, allowed_models on groups
--- ---

View File

@@ -409,7 +409,7 @@ func (p *OIDCProvider) findOrCreateOIDCGroup(ctx context.Context, name, createdB
Description: "Synced from OIDC", Description: "Synced from OIDC",
Scope: "global", Scope: "global",
Source: "oidc", Source: "oidc",
CreatedBy: createdBy, CreatedBy: &createdBy,
} }
if err := stores.Groups.Create(ctx, g); err != nil { if err := stores.Groups.Create(ctx, g); err != nil {
log.Printf("[auth/oidc] warn: could not create group %s: %v", name, err) log.Printf("[auth/oidc] warn: could not create group %s: %v", name, err)

164
server/auth/permissions.go Normal file
View File

@@ -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
}

View File

@@ -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;

View File

@@ -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')
);

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" 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/database"
"git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models" "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 // Include admin default model so frontend can use it in resolution chain
defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model") 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}) c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel})
} }

View File

@@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/auth"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "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) 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 // Determine streaming
stream := true // default stream := true // default
if req.Stream != nil { if req.Stream != nil {

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin" "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/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/notifications"
@@ -22,8 +23,15 @@ type createGroupRequest struct {
} }
type updateGroupRequest struct { type updateGroupRequest struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,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 { type addGroupMemberRequest struct {
@@ -85,7 +93,7 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
Description: req.Description, Description: req.Description,
Scope: req.Scope, Scope: req.Scope,
TeamID: req.TeamID, TeamID: req.TeamID,
CreatedBy: actorID, CreatedBy: &actorID,
} }
if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil { if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil {
@@ -132,12 +140,34 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
return return
} }
if req.Name == nil && req.Description == nil { patch := models.GroupPatch{
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) Name: req.Name,
return 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 { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return return
@@ -166,6 +196,11 @@ func (h *GroupHandler) DeleteGroup(c *gin.Context) {
return return
} }
if err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return return
} }
@@ -382,3 +417,28 @@ func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "grant.delete", resourceType, resourceID, nil) 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})
}

View File

@@ -419,7 +419,7 @@ func main() {
// Channels // Channels
channels := handlers.NewChannelHandler() channels := handlers.NewChannelHandler()
protected.GET("/channels", channels.ListChannels) 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.GET("/channels/:id", channels.GetChannel)
protected.PUT("/channels/:id", channels.UpdateChannel) protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel) protected.DELETE("/channels/:id", channels.DeleteChannel)
@@ -498,7 +498,7 @@ func main() {
// Channel participants (v0.23.0 — ICD §3.7) // Channel participants (v0.23.0 — ICD §3.7)
partH := handlers.NewParticipantHandler(stores) partH := handlers.NewParticipantHandler(stores)
protected.GET("/channels/:id/participants", partH.List) 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.PATCH("/channels/:id/participants/:participantId", partH.Update)
protected.DELETE("/channels/:id/participants/:participantId", partH.Remove) protected.DELETE("/channels/:id/participants/:participantId", partH.Remove)
@@ -573,9 +573,9 @@ func main() {
// Personas // Personas
personas := handlers.NewPersonaHandler(stores) personas := handlers.NewPersonaHandler(stores)
protected.GET("/personas", personas.ListUserPersonas) protected.GET("/personas", personas.ListUserPersonas)
protected.POST("/personas", personas.CreateUserPersona) protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona)
protected.PUT("/personas/:id", personas.UpdateUserPersona) protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona)
protected.DELETE("/personas/:id", personas.DeleteUserPersona) protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona)
protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar) protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar) protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
@@ -692,12 +692,12 @@ func main() {
// Knowledge Bases (RAG — v0.14.0) // Knowledge Bases (RAG — v0.14.0)
kbH := handlers.NewKnowledgeBaseHandler(stores, objStore, kbIngester, kbEmbedder) 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", kbH.ListKBs)
protected.GET("/knowledge-bases/:id", kbH.GetKB) protected.GET("/knowledge-bases/:id", kbH.GetKB)
protected.PUT("/knowledge-bases/:id", kbH.UpdateKB) protected.PUT("/knowledge-bases/:id", kbH.UpdateKB)
protected.DELETE("/knowledge-bases/:id", kbH.DeleteKB) 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", kbH.ListDocuments)
protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus) protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
protected.DELETE("/knowledge-bases/:id/documents/:docId", kbH.DeleteDocument) protected.DELETE("/knowledge-bases/:id/documents/:docId", kbH.DeleteDocument)
@@ -859,6 +859,10 @@ func main() {
admin.POST("/groups/:id/members", groupAdm.AddMember) admin.POST("/groups/:id/members", groupAdm.AddMember)
admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember) 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) // Resource Grants (admin — v0.16.0)
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant) admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant) admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)

View File

@@ -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
}

View File

@@ -859,13 +859,30 @@ const (
// Group is an access-control list. Decouples resource visibility from teams. // Group is an access-control list. Decouples resource visibility from teams.
type Group struct { type Group struct {
BaseModel BaseModel
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"` Description string `json:"description" db:"description"`
Scope string `json:"scope" db:"scope"` // global, team Scope string `json:"scope" db:"scope"` // global, team
TeamID *string `json:"team_id,omitempty" db:"team_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"`
CreatedBy string `json:"created_by" db:"created_by"` CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
Source string `json:"source" db:"source"` // manual (default), oidc Source string `json:"source" db:"source"` // manual (default), oidc, system
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column 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. // GroupMember links a user to a group.

View File

@@ -476,7 +476,7 @@ type KnowledgeBaseStore interface {
type GroupStore interface { type GroupStore interface {
Create(ctx context.Context, g *models.Group) error Create(ctx context.Context, g *models.Group) error
GetByID(ctx context.Context, id string) (*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 Delete(ctx context.Context, id string) error
// Scoped listing // Scoped listing

View File

@@ -3,6 +3,8 @@ package postgres
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors"
"fmt" "fmt"
"git.gobha.me/xcaliber/chat-switchboard/models" "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 == "" { if g.Source == "" {
g.Source = "manual" 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, ` return DB.QueryRowContext(ctx, `
INSERT INTO groups (name, description, scope, team_id, created_by, source) INSERT INTO groups (name, description, scope, team_id, created_by, source, permissions)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`, RETURNING id, created_at, updated_at`,
g.Name, g.Description, g.Scope, 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) ).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
} }
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) { func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
var g models.Group row := DB.QueryRowContext(ctx, `
var teamID sql.NullString
err := DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual') COALESCE(g.source, 'manual'),
FROM groups g WHERE g.id = $1`, id).Scan( COALESCE(g.permissions, '[]'::jsonb),
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy, g.token_budget_daily,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source, g.token_budget_monthly,
) g.allowed_models
if err != nil { FROM groups g WHERE g.id = $1`, id)
return nil, err return scanGroup(row)
}
g.TeamID = NullableStringPtr(teamID)
return &g, nil
} }
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error { // Update applies a patch to a group.
b := NewUpdate("groups") // The Everyone group (source=system) allows permission/budget edits but not
if name != nil { // name, description, or structural changes — those fields are silently ignored.
b.Set("name", *name) 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() { if !b.HasSets() {
return nil return nil
@@ -68,7 +108,18 @@ func (s *GroupStore) Update(ctx context.Context, id string, name, description *s
return nil return nil
} }
// Delete removes a group. Returns an error if source=system.
func (s *GroupStore) Delete(ctx context.Context, id string) error { 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) res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id)
if err != nil { if err != nil {
return err return err
@@ -81,33 +132,31 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
// ── Scoped Listing ────────────────────────── // ── 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) { func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, ` return queryGroups(ctx, `SELECT`+groupSelectCols+`
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')
FROM groups g FROM groups g
ORDER BY g.scope, g.name`) ORDER BY g.scope, g.name`)
} }
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) { func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
return queryGroups(ctx, ` return queryGroups(ctx, `SELECT`+groupSelectCols+`
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')
FROM groups g FROM groups g
WHERE g.scope = 'team' AND g.team_id = $1 WHERE g.scope = 'team' AND g.team_id = $1
ORDER BY g.name`, teamID) ORDER BY g.name`, teamID)
} }
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) { func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
return queryGroups(ctx, ` return queryGroups(ctx, `SELECT`+groupSelectCols+`
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')
FROM groups g FROM groups g
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1) WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1)
ORDER BY g.scope, g.name`, userID) ORDER BY g.scope, g.name`, userID)
@@ -191,6 +240,40 @@ func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]stri
// ── Scanners ──────────────────────────────── // ── 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) { func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) {
rows, err := DB.QueryContext(ctx, q, args...) rows, err := DB.QueryContext(ctx, q, args...)
if err != nil { if err != nil {
@@ -202,12 +285,42 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
for rows.Next() { for rows.Next() {
var g models.Group var g models.Group
var teamID sql.NullString var teamID sql.NullString
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, var createdBy sql.NullString
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source); err != nil { 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 return nil, err
} }
g.TeamID = NullableStringPtr(teamID) 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) result = append(result, g)
} }
return result, rows.Err() 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
}

View File

@@ -44,6 +44,13 @@ func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
return b 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. // SetJSON adds a JSONB column from a map.
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder { func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
data, err := json.Marshal(val) data, err := json.Marshal(val)

View File

@@ -3,6 +3,8 @@ package sqlite
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors"
"fmt" "fmt"
"time" "time"
@@ -24,11 +26,18 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
if g.Source == "" { if g.Source == "" {
g.Source = "manual" g.Source = "manual"
} }
_, err := DB.ExecContext(ctx, ` if g.Permissions == nil {
INSERT INTO groups (id, name, description, scope, team_id, created_by, source, created_at, updated_at) g.Permissions = []string{}
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, }
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, 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), now.Format(timeFmt), now.Format(timeFmt),
) )
return err 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) { func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
var g models.Group var g models.Group
var teamID sql.NullString 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, ` err := DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, (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( 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, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
g.TeamID = NullableStringPtr(teamID) 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 return &g, nil
} }
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error { // Update applies a patch to a group.
b := NewUpdate("groups") // system groups allow permission/budget edits but not structural field changes.
if name != nil { func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
b.Set("name", *name) 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() { if !b.HasSets() {
return nil return nil
@@ -75,7 +140,18 @@ func (s *GroupStore) Update(ctx context.Context, id string, name, description *s
return nil return nil
} }
// Delete removes a group. Returns an error if source=system.
func (s *GroupStore) Delete(ctx context.Context, id string) error { 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) res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id)
if err != nil { if err != nil {
return err return err
@@ -88,33 +164,31 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
// ── Scoped Listing ────────────────────────── // ── 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) { func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, ` return queryGroups(ctx, `SELECT `+groupSelectCols+`
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')
FROM groups g FROM groups g
ORDER BY g.scope, g.name`) ORDER BY g.scope, g.name`)
} }
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) { func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
return queryGroups(ctx, ` return queryGroups(ctx, `SELECT `+groupSelectCols+`
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')
FROM groups g FROM groups g
WHERE g.scope = 'team' AND g.team_id = ? WHERE g.scope = 'team' AND g.team_id = ?
ORDER BY g.name`, teamID) ORDER BY g.name`, teamID)
} }
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) { func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
return queryGroups(ctx, ` return queryGroups(ctx, `SELECT `+groupSelectCols+`
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')
FROM groups g FROM groups g
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?) WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?)
ORDER BY g.scope, g.name`, userID) 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() { for rows.Next() {
var g models.Group var g models.Group
var teamID sql.NullString var teamID sql.NullString
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, var createdBy sql.NullString
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source); err != nil { 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 return nil, err
} }
g.TeamID = NullableStringPtr(teamID) 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) result = append(result, g)
} }
return result, rows.Err() 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
}

View File

@@ -97,6 +97,11 @@ func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
return b 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 { func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
data, err := json.Marshal(val) data, err := json.Marshal(val)
if err != nil { if err != nil {

View File

@@ -51,6 +51,43 @@
'<h4 id="adminGroupDetailName" style="margin:0"></h4>' + '<h4 id="adminGroupDetailName" style="margin:0"></h4>' +
'<span id="adminGroupDetailMeta" class="text-muted"></span>' + '<span id="adminGroupDetailMeta" class="text-muted"></span>' +
'</div>' + '</div>' +
/* ── Permissions (v0.24.2) ── */
'<div class="settings-section" style="margin-bottom:16px">' +
'<h5 style="margin:0 0 8px">Permissions</h5>' +
'<div id="adminGroupPermissions" class="admin-permissions-grid"></div>' +
'</div>' +
/* ── Token Budgets ── */
'<div class="settings-section" style="margin-bottom:16px">' +
'<h5 style="margin:0 0 8px">Token Budgets</h5>' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:1"><label>Daily limit</label>' +
'<input type="number" id="adminGroupBudgetDaily" placeholder="Unlimited" min="0" style="width:100%">' +
'</div>' +
'<div class="form-group" style="flex:1"><label>Monthly limit</label>' +
'<input type="number" id="adminGroupBudgetMonthly" placeholder="Unlimited" min="0" style="width:100%">' +
'</div>' +
'</div>' +
'</div>' +
/* ── Model Allowlist ── */
'<div class="settings-section" style="margin-bottom:16px">' +
'<h5 style="margin:0 0 8px">Allowed Models</h5>' +
'<p class="text-muted" style="font-size:12px;margin:0 0 8px">Leave empty for unrestricted access to all enabled models.</p>' +
'<div id="adminGroupModels" class="admin-models-grid"></div>' +
'</div>' +
/* ── Save button ── */
'<div style="margin-bottom:16px">' +
'<button class="btn-small btn-primary" id="adminGroupSavePermsBtn">Save Permissions &amp; Budgets</button>' +
'<span id="adminGroupSaveStatus" class="text-muted" style="margin-left:8px;font-size:12px"></span>' +
'</div>' +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
/* ── Members (existing) ── */
'<h5 style="margin:0 0 8px">Members</h5>' +
'<div id="adminGroupMemberList" class="admin-list"></div>' + '<div id="adminGroupMemberList" class="admin-list"></div>' +
'<div id="adminAddGroupMemberForm" class="admin-inline-form" style="display:none">' + '<div id="adminAddGroupMemberForm" class="admin-inline-form" style="display:none">' +
'<div class="form-row">' + '<div class="form-row">' +

View File

@@ -621,6 +621,10 @@ const API = {
}, },
adminDeleteGrant(type, id) { return this._del(`/api/v1/admin/grants/${type}/${id}`); }, adminDeleteGrant(type, id) { return this._del(`/api/v1/admin/grants/${type}/${id}`); },
// ── Permissions (v0.24.2) ───────────────
adminListPermissions() { return this._get('/api/v1/admin/permissions'); },
adminGetUserPermissions(userId) { return this._get(`/api/v1/admin/users/${userId}/permissions`); },
// ── User Groups ───────────────────────── // ── User Groups ─────────────────────────
listMyGroups() { return this._get('/api/v1/groups/mine'); }, listMyGroups() { return this._get('/api/v1/groups/mine'); },

View File

@@ -916,16 +916,94 @@ Object.assign(UI, {
document.getElementById('adminGroupDetailName').textContent = groupName; document.getElementById('adminGroupDetailName').textContent = groupName;
document.getElementById('adminAddGroupMemberForm').style.display = 'none'; document.getElementById('adminAddGroupMemberForm').style.display = 'none';
// Load group details for meta line // Load group + available permissions + enabled models in parallel
try { try {
const g = await API.adminGetGroup(groupId); const [g, permResp, modelsResp] = await Promise.all([
API.adminGetGroup(groupId),
API.adminListPermissions(),
API.listEnabledModels(),
]);
const scope = g.scope === 'global' ? 'Global group' : 'Team-scoped group'; const scope = g.scope === 'global' ? 'Global group' : 'Team-scoped group';
document.getElementById('adminGroupDetailMeta').textContent = `${scope} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}`; document.getElementById('adminGroupDetailMeta').textContent = `${scope} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}`;
} catch (e) { /* proceed */ }
// ── Permission checkboxes ──
const allPerms = permResp.permissions || [];
const groupPerms = new Set(g.permissions || []);
const permDesc = {
'model.use': 'Use models for completions',
'model.select_any': 'Use any enabled model',
'kb.read': 'Search knowledge bases',
'kb.write': 'Upload / delete KB documents',
'kb.create': 'Create knowledge bases',
'channel.create': 'Create channels',
'channel.invite': 'Invite users to channels',
'persona.create': 'Create personas',
'persona.manage': 'Edit / delete team personas',
'workflow.create': 'Create workflows',
'admin.view': 'Read-only admin access',
'token.unlimited': 'Bypass token budgets',
};
const permEl = document.getElementById('adminGroupPermissions');
permEl.innerHTML = allPerms.map(p => `<label class="checkbox-label" style="font-size:12px;display:flex;align-items:baseline;gap:6px;margin-bottom:4px">
<input type="checkbox" class="group-perm-cb" value="${esc(p)}" ${groupPerms.has(p) ? 'checked' : ''}>
<span><strong>${esc(p)}</strong> <span class="text-muted">\u2014 ${esc(permDesc[p] || p)}</span></span>
</label>`).join('');
// ── Token budget fields ──
document.getElementById('adminGroupBudgetDaily').value = g.token_budget_daily != null ? g.token_budget_daily : '';
document.getElementById('adminGroupBudgetMonthly').value = g.token_budget_monthly != null ? g.token_budget_monthly : '';
// ── Model allowlist checkboxes ──
const models = (modelsResp.models || []).filter(m => !m.is_persona);
const allowedSet = g.allowed_models ? new Set(g.allowed_models) : null;
const modelEl = document.getElementById('adminGroupModels');
const seen = new Set();
const unique = models.filter(m => { if (seen.has(m.model_id)) return false; seen.add(m.model_id); return true; });
unique.sort((a, b) => a.model_id.localeCompare(b.model_id));
modelEl.innerHTML = unique.map(m => `<label class="checkbox-label" style="font-size:12px;display:flex;align-items:baseline;gap:6px;margin-bottom:4px">
<input type="checkbox" class="group-model-cb" value="${esc(m.model_id)}" ${allowedSet === null || allowedSet.has(m.model_id) ? 'checked' : ''}>
<span>${esc(m.display_name || m.model_id)} <span class="text-muted">${esc(m.provider_name || '')}</span></span>
</label>`).join('') || '<span class="text-muted" style="font-size:12px">No models available</span>';
} catch (e) {
console.error('openGroupDetail:', e);
}
// Wire save button
document.getElementById('adminGroupSavePermsBtn').onclick = () => this._saveGroupPerms(groupId);
await this.loadGroupMembers(groupId); await this.loadGroupMembers(groupId);
}, },
async _saveGroupPerms(groupId) {
const status = document.getElementById('adminGroupSaveStatus');
status.textContent = 'Saving\u2026';
const perms = [...document.querySelectorAll('.group-perm-cb:checked')].map(cb => cb.value);
const dailyRaw = document.getElementById('adminGroupBudgetDaily').value.trim();
const monthlyRaw = document.getElementById('adminGroupBudgetMonthly').value.trim();
const allModelCbs = document.querySelectorAll('.group-model-cb');
const checkedModelCbs = document.querySelectorAll('.group-model-cb:checked');
const allChecked = allModelCbs.length > 0 && checkedModelCbs.length === allModelCbs.length;
const update = { permissions: perms };
if (dailyRaw === '') { update.clear_budget_daily = true; }
else { update.token_budget_daily = parseInt(dailyRaw, 10); }
if (monthlyRaw === '') { update.clear_budget_monthly = true; }
else { update.token_budget_monthly = parseInt(monthlyRaw, 10); }
if (allChecked || allModelCbs.length === 0) { update.clear_allowed_models = true; }
else { update.allowed_models = [...checkedModelCbs].map(cb => cb.value); }
try {
await API.adminUpdateGroup(groupId, update);
status.textContent = 'Saved \u2713';
setTimeout(() => { status.textContent = ''; }, 2000);
} catch (e) {
status.textContent = 'Error: ' + e.message;
}
},
async loadGroupMembers(groupId) { async loadGroupMembers(groupId) {
const el = document.getElementById('adminGroupMemberList'); const el = document.getElementById('adminGroupMemberList');
el.innerHTML = '<div class="loading">Loading...</div>'; el.innerHTML = '<div class="loading">Loading...</div>';