Changeset 0.24.2 (#158)
This commit is contained in:
22
CHANGELOG.md
22
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
164
server/auth/permissions.go
Normal 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
|
||||||
|
}
|
||||||
36
server/database/migrations/020_permissions.sql
Normal file
36
server/database/migrations/020_permissions.sql
Normal 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;
|
||||||
25
server/database/migrations/sqlite/020_permissions.sql
Normal file
25
server/database/migrations/sqlite/020_permissions.sql
Normal 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')
|
||||||
|
);
|
||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
60
server/middleware/permissions.go
Normal file
60
server/middleware/permissions.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 & 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">' +
|
||||||
|
|||||||
@@ -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'); },
|
||||||
|
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
Reference in New Issue
Block a user