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.
## [0.24.2] — 2026-03-07
### Added
- **Fine-grained permission system.** 12 permission constants using `domain.action` convention (`model.use`, `model.select_any`, `kb.read`, `kb.write`, `kb.create`, `channel.create`, `channel.invite`, `persona.create`, `persona.manage`, `workflow.create`, `admin.view`, `token.unlimited`). `AllPermissions` slice for UI rendering and validation.
- **Permission resolution.** `auth.ResolvePermissions()` computes effective permissions as the union of the Everyone group plus all explicitly assigned group memberships. Admin users bypass checks entirely.
- **`RequirePermission()` middleware.** Gin middleware with per-request permission caching — computed at most once regardless of how many permission gates are chained. `GetResolvedPermissions()` helper for conditional logic in handlers.
- **Permission-gated routes.** `POST /personas` requires `persona.create`. `PUT/DELETE /personas/:id` requires `persona.manage`. `POST /knowledge-bases` requires `kb.create`. `POST /knowledge-bases/:id/documents` requires `kb.write`. `POST /channels` requires `channel.create`. `POST /channels/:id/participants` requires `channel.invite`.
- **Token budgets.** Per-group daily and monthly token ceilings stored as BIGINT columns on groups. `auth.ResolveTokenBudget()` returns the most restrictive budget across all memberships. Pre-flight enforcement in the completion handler — queries `usage_log` for current period totals, returns 429 when exceeded. BYOK bypass: personal-scope providers skip budget checks entirely.
- **Model access control.** Per-group `allowed_models` JSONB column (NULL = unrestricted). `auth.ResolveModelAllowlist()` unions all group allowlists — any group with NULL means unrestricted. Enforced in both the completion handler (403 on disallowed model) and the model list endpoint (filters response so users only see permitted models).
- **Everyone group.** Implicit group with stable well-known ID (`00000000-...0001`), `source=system`, seeded in migration 020. All authenticated users receive its permissions without explicit membership. Editable in admin — replaces the previously planned `DefaultUserPerms` / `global_config` approach. `source=system` guard prevents deletion (returns 400 from the store layer).
- **Admin permissions endpoints.** `GET /api/v1/admin/permissions` returns all valid permission strings. `GET /api/v1/admin/users/:id/permissions` returns effective resolved permissions for a user.
- **Admin group permissions UI.** Group detail view expanded with three new sections: permission checklist (checkbox per permission with description), token budget fields (daily/monthly, empty = unlimited), and model allowlist multi-select (deduped by model_id, all-checked = unrestricted). Single save button sends all fields in one PUT.
- **API methods.** `adminListPermissions()`, `adminGetUserPermissions(userId)`.
### Fixed
- **OIDC group creation compile error.** `auth/oidc.go` line 412 passed `string` to `*string` field (`Group.CreatedBy`). Fixed to `&createdBy`.
### Migration Notes
- **Migration 020:** Adds `permissions` (JSONB, default `'[]'`), `token_budget_daily` (BIGINT), `token_budget_monthly` (BIGINT), `allowed_models` (JSONB) columns to `groups` table. Extends `groups.source` CHECK to include `system`. Drops `NOT NULL` on `created_by` (NULL for system-seeded groups). Seeds the Everyone group with `model.use`, `kb.read`, `channel.create` permissions.
- Both Postgres and SQLite migrations included.
- **No new env vars.**
## [0.24.1] — 2026-03-07
### Added

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.';
```
### The Everyone Group
Rather than hardcoding base permissions in application code, the system
seeds a special **Everyone** group at migration time. Every authenticated
user implicitly receives its permissions without explicit membership.
Properties:
- `id` = `00000000-0000-0000-0000-000000000001` (stable, well-known)
- `name` = `"Everyone"`
- `scope` = `"global"`
- `source` = `"system"` (guards against deletion)
The Everyone group is editable in the admin UI — an org that wants
locked-down defaults empties it; an open install leaves it permissive.
`DefaultUserPerms` via `global_config` (previously in ROADMAP) is
superseded by this approach: it was a one-time init value anyway.
The migration seeds the group; subsequent changes are live data.
`Delete` on a `source=system` group returns `400 Bad Request`. The guard
lives in the store layer, not the handler, so it can't be bypassed.
### Permission Resolution
A user's effective permissions are the **union** of all their groups'
permissions, plus base permissions that every active user gets.
A user's effective permissions are the **union** of the Everyone group's
permissions plus all their explicitly assigned groups' permissions.
```go
// server/auth/permissions.go
// Base permissions granted to all active users (no group needed)
var basePermissions = map[string]bool{
"model.use": true,
"kb.read": true,
"channel.create": true,
}
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
// ResolvePermissions returns the effective permission set for a user.
// Always includes the Everyone group regardless of membership.
// Admin users bypass this entirely — callers should check role first.
func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
perms := make(map[string]bool)
// Start with base permissions
for k, v := range basePermissions {
perms[k] = v
// Always apply Everyone group
everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID)
if err == nil {
for _, p := range everyone.Permissions {
perms[p] = true
}
}
// Union all group permissions
// Union all explicit group memberships
groups, err := stores.Groups.ListForUser(ctx, userID)
if err != nil {
return perms, err
@@ -723,6 +744,21 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string)
Admin users (`role=admin`) bypass permission checks entirely, same
as they bypass team membership checks today.
### BYOK Budget Bypass
Token budget enforcement only applies when the completion draws from a
**team or global provider**. If `providerScope == "personal"` the request
is the user's own money — budget pre-flight is skipped entirely. The
`providerScope` value is already resolved by `resolveConfig()` before
the completion handler begins streaming.
### Anonymous Sessions
v0.24.3 session participants carry `SessionClaims`, not `UserClaims`.
They never enter group-based permission resolution. Their capability is
bounded to their channel and the persona bound to it by the workflow
definition. Do not conflate the two paths.
### Middleware Helper
```go
@@ -813,9 +849,15 @@ Group edit form gains:
### What Ships
- Migration 019 (group permissions, token budgets, model allowlist)
- `server/auth/permissions.go` (resolution logic)
- `middleware/permissions.go` (`RequirePermission()`)
- Migration 020 (group permissions, token budgets, model allowlist, Everyone group seed)
- `server/auth/permissions.go` (constants, resolution logic, budget resolution)
- `middleware/permissions.go` (`RequirePermission()` with request-context cache)
- Permission-gated handler routes
- Token budget pre-flight in completion handler (skipped for personal/BYOK providers)
- Model access filtering in model list endpoint
- Admin UI: group permissions editor, user effective permissions view
- Both Postgres and SQLite store implementations
- Everyone group: seeded in migration, guarded from deletion in store layer
- Permission-gated handler routes
- Token budget pre-flight in completion handler
- Model access filtering in model list endpoint

View File

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

View File

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

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"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/models"
@@ -56,6 +57,23 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
// Include admin default model so frontend can use it in resolution chain
defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model")
// Model allowlist filtering (v0.24.2) — non-admin users with group-level
// model restrictions only see permitted models. Persona entries whose
// underlying model is disallowed are also filtered.
role, _ := c.Get("role")
if role != "admin" {
allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID)
if err == nil && allowlist != nil {
filtered := make([]models.UserModel, 0, len(userModels))
for _, m := range userModels {
if allowlist[m.ModelID] {
filtered = append(filtered, m)
}
}
userModels = filtered
}
}
c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel})
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/auth"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
@@ -625,6 +626,44 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
}
// ── Permission pre-flight ─────────────────────────────────────────────
// Budget enforcement and model allowlist only apply when the request draws
// from a team or global provider. BYOK (personal scope) is the user's money
// — no ceiling applies.
role, _ := c.Get("role")
if role != "admin" && providerScope != "personal" {
// Token budget
if h.stores.Usage != nil {
budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID)
if err == nil && budget != nil {
var opts store.UsageQueryOptions
now := time.Now()
if budget.Period == "daily" {
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
opts.Since = &dayStart
} else {
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
opts.Since = &monthStart
}
totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts)
if err == nil && totals != nil {
used := int64(totals.InputTokens + totals.OutputTokens)
if used >= budget.Limit {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "token budget exceeded"})
return
}
}
}
}
// Model allowlist
allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID)
if err == nil && allowlist != nil && !allowlist[model] {
c.JSON(http.StatusForbidden, gin.H{"error": "model not permitted for your account: " + model})
return
}
}
// Determine streaming
stream := true // default
if req.Stream != nil {

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
@@ -22,8 +23,15 @@ type createGroupRequest struct {
}
type updateGroupRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Permissions *[]string `json:"permissions,omitempty"`
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"`
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"`
AllowedModels *[]string `json:"allowed_models,omitempty"`
ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"`
ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"`
ClearAllowedModels bool `json:"clear_allowed_models,omitempty"`
}
type addGroupMemberRequest struct {
@@ -85,7 +93,7 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
Description: req.Description,
Scope: req.Scope,
TeamID: req.TeamID,
CreatedBy: actorID,
CreatedBy: &actorID,
}
if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil {
@@ -132,12 +140,34 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
return
}
if req.Name == nil && req.Description == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
patch := models.GroupPatch{
Name: req.Name,
Description: req.Description,
Permissions: req.Permissions,
TokenBudgetDaily: req.TokenBudgetDaily,
TokenBudgetMonthly: req.TokenBudgetMonthly,
AllowedModels: req.AllowedModels,
ClearBudgetDaily: req.ClearBudgetDaily,
ClearBudgetMonthly: req.ClearBudgetMonthly,
ClearAllowedModels: req.ClearAllowedModels,
}
err := h.stores.Groups.Update(c.Request.Context(), id, req.Name, req.Description)
// Validate permissions if provided
if patch.Permissions != nil {
valid := auth.AllPermissions
validSet := make(map[string]bool, len(valid))
for _, p := range valid {
validSet[p] = true
}
for _, p := range *patch.Permissions {
if !validSet[p] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown permission: " + p})
return
}
}
}
err := h.stores.Groups.Update(c.Request.Context(), id, patch)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
@@ -166,6 +196,11 @@ func (h *GroupHandler) DeleteGroup(c *gin.Context) {
return
}
if err != nil {
// Store layer returns a plain error for system groups.
if err.Error() == "system groups cannot be deleted" {
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
@@ -382,3 +417,28 @@ func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "grant.delete", resourceType, resourceID, nil)
}
// ListPermissions returns all valid permission strings.
// GET /api/v1/admin/permissions
func (h *GroupHandler) ListPermissions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"permissions": auth.AllPermissions})
}
// GetUserPermissions returns the effective permissions for a given user.
// GET /api/v1/admin/users/:id/permissions
func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
userID := c.Param("id")
perms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
return
}
// Convert map to sorted slice for stable output
list := make([]string, 0, len(perms))
for p := range perms {
list = append(list, p)
}
c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID})
}

View File

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

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.
type Group struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Scope string `json:"scope" db:"scope"` // global, team
TeamID *string `json:"team_id,omitempty" db:"team_id"`
CreatedBy string `json:"created_by" db:"created_by"`
Source string `json:"source" db:"source"` // manual (default), oidc
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Scope string `json:"scope" db:"scope"` // global, team
TeamID *string `json:"team_id,omitempty" db:"team_id"`
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
Source string `json:"source" db:"source"` // manual (default), oidc, system
Permissions []string `json:"permissions" db:"permissions"`
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty" db:"token_budget_daily"`
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty" db:"token_budget_monthly"`
AllowedModels []string `json:"allowed_models,omitempty" db:"allowed_models"` // nil = unrestricted
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
}
// GroupPatch holds optional fields for updating a group.
type GroupPatch struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Permissions *[]string `json:"permissions,omitempty"`
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"`
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"`
AllowedModels *[]string `json:"allowed_models,omitempty"` // &[]string{} = restrict to none; nil = no change
ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"`
ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"`
ClearAllowedModels bool `json:"clear_allowed_models,omitempty"`
}
// GroupMember links a user to a group.

View File

@@ -476,7 +476,7 @@ type KnowledgeBaseStore interface {
type GroupStore interface {
Create(ctx context.Context, g *models.Group) error
GetByID(ctx context.Context, id string) (*models.Group, error)
Update(ctx context.Context, id string, name, description *string) error
Update(ctx context.Context, id string, patch models.GroupPatch) error
Delete(ctx context.Context, id string) error
// Scoped listing

View File

@@ -3,6 +3,8 @@ package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
@@ -18,41 +20,79 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
if g.Source == "" {
g.Source = "manual"
}
if g.Permissions == nil {
g.Permissions = []string{}
}
permsJSON, err := json.Marshal(g.Permissions)
if err != nil {
return fmt.Errorf("marshal permissions: %w", err)
}
return DB.QueryRowContext(ctx, `
INSERT INTO groups (name, description, scope, team_id, created_by, source)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO groups (name, description, scope, team_id, created_by, source, permissions)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`,
g.Name, g.Description, g.Scope,
models.NullString(g.TeamID), g.CreatedBy, g.Source,
models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, permsJSON,
).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
}
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
var g models.Group
var teamID sql.NullString
err := DB.QueryRowContext(ctx, `
row := DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual')
FROM groups g WHERE g.id = $1`, id).Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
)
if err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
return &g, nil
COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'::jsonb),
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models
FROM groups g WHERE g.id = $1`, id)
return scanGroup(row)
}
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error {
b := NewUpdate("groups")
if name != nil {
b.Set("name", *name)
// Update applies a patch to a group.
// The Everyone group (source=system) allows permission/budget edits but not
// name, description, or structural changes — those fields are silently ignored.
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
return err
}
if description != nil {
b.Set("description", *description)
b := NewUpdate("groups")
if source != "system" {
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Description != nil {
b.Set("description", *patch.Description)
}
}
if patch.Permissions != nil {
permsJSON, err := json.Marshal(*patch.Permissions)
if err != nil {
return fmt.Errorf("marshal permissions: %w", err)
}
b.Set("permissions", permsJSON)
}
if patch.ClearBudgetDaily {
b.SetNull("token_budget_daily")
} else if patch.TokenBudgetDaily != nil {
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
}
if patch.ClearBudgetMonthly {
b.SetNull("token_budget_monthly")
} else if patch.TokenBudgetMonthly != nil {
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
}
if patch.ClearAllowedModels {
b.SetNull("allowed_models")
} else if patch.AllowedModels != nil {
modelsJSON, err := json.Marshal(*patch.AllowedModels)
if err != nil {
return fmt.Errorf("marshal allowed_models: %w", err)
}
b.Set("allowed_models", modelsJSON)
}
if !b.HasSets() {
return nil
@@ -68,7 +108,18 @@ func (s *GroupStore) Update(ctx context.Context, id string, name, description *s
return nil
}
// Delete removes a group. Returns an error if source=system.
func (s *GroupStore) Delete(ctx context.Context, id string) error {
var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return sql.ErrNoRows
}
return err
}
if source == "system" {
return fmt.Errorf("system groups cannot be deleted")
}
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id)
if err != nil {
return err
@@ -81,33 +132,31 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
// ── Scoped Listing ──────────────────────────
const groupSelectCols = `
g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'::jsonb),
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models`
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
COALESCE(g.source, 'manual')
return queryGroups(ctx, `SELECT`+groupSelectCols+`
FROM groups g
ORDER BY g.scope, g.name`)
}
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
COALESCE(g.source, 'manual')
return queryGroups(ctx, `SELECT`+groupSelectCols+`
FROM groups g
WHERE g.scope = 'team' AND g.team_id = $1
ORDER BY g.name`, teamID)
}
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
COALESCE(g.source, 'manual')
return queryGroups(ctx, `SELECT`+groupSelectCols+`
FROM groups g
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1)
ORDER BY g.scope, g.name`, userID)
@@ -191,6 +240,40 @@ func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]stri
// ── Scanners ────────────────────────────────
type groupScanner interface {
Scan(dest ...any) error
}
func scanGroup(row groupScanner) (*models.Group, error) {
var g models.Group
var teamID sql.NullString
var createdBy sql.NullString
var permsJSON []byte
var budgetDaily, budgetMonthly sql.NullInt64
var allowedJSON []byte
if err := row.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsJSON)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
g.AllowedModels = unmarshalStringSlice(allowedJSON)
}
return &g, nil
}
func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
@@ -202,12 +285,42 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
for rows.Next() {
var g models.Group
var teamID sql.NullString
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source); err != nil {
var createdBy sql.NullString
var permsJSON []byte
var budgetDaily, budgetMonthly sql.NullInt64
var allowedJSON []byte
if err := rows.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsJSON)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
g.AllowedModels = unmarshalStringSlice(allowedJSON)
}
result = append(result, g)
}
return result, rows.Err()
}
func unmarshalStringSlice(data []byte) []string {
if len(data) == 0 {
return []string{}
}
var s []string
if err := json.Unmarshal(data, &s); err != nil {
return []string{}
}
return s
}

View File

@@ -44,6 +44,13 @@ func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
return b
}
// SetNull adds a col = NULL pair to the UPDATE.
func (b *UpdateBuilder) SetNull(col string) *UpdateBuilder {
b.argIdx++
b.sets = append(b.sets, fmt.Sprintf("%s = NULL", col))
return b
}
// SetJSON adds a JSONB column from a map.
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
data, err := json.Marshal(val)

View File

@@ -3,6 +3,8 @@ package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
@@ -24,11 +26,18 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
if g.Source == "" {
g.Source = "manual"
}
_, err := DB.ExecContext(ctx, `
INSERT INTO groups (id, name, description, scope, team_id, created_by, source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
if g.Permissions == nil {
g.Permissions = []string{}
}
permsJSON, err := json.Marshal(g.Permissions)
if err != nil {
return fmt.Errorf("marshal permissions: %w", err)
}
_, err = DB.ExecContext(ctx, `
INSERT INTO groups (id, name, description, scope, team_id, created_by, source, permissions, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
g.ID, g.Name, g.Description, g.Scope,
models.NullString(g.TeamID), g.CreatedBy, g.Source,
models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, string(permsJSON),
now.Format(timeFmt), now.Format(timeFmt),
)
return err
@@ -37,29 +46,85 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) {
var g models.Group
var teamID sql.NullString
var createdBy sql.NullString
var permsStr sql.NullString
var budgetDaily, budgetMonthly sql.NullInt64
var allowedStr sql.NullString
err := DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual')
COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'),
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models
FROM groups g WHERE g.id = ?`, id).Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
)
if err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsStr.String)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if allowedStr.Valid && allowedStr.String != "" {
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
}
return &g, nil
}
func (s *GroupStore) Update(ctx context.Context, id string, name, description *string) error {
b := NewUpdate("groups")
if name != nil {
b.Set("name", *name)
// Update applies a patch to a group.
// system groups allow permission/budget edits but not structural field changes.
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil {
return err
}
if description != nil {
b.Set("description", *description)
b := NewUpdate("groups")
if source != "system" {
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Description != nil {
b.Set("description", *patch.Description)
}
}
if patch.Permissions != nil {
permsJSON, err := json.Marshal(*patch.Permissions)
if err != nil {
return fmt.Errorf("marshal permissions: %w", err)
}
b.Set("permissions", string(permsJSON))
}
if patch.ClearBudgetDaily {
b.SetNull("token_budget_daily")
} else if patch.TokenBudgetDaily != nil {
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
}
if patch.ClearBudgetMonthly {
b.SetNull("token_budget_monthly")
} else if patch.TokenBudgetMonthly != nil {
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
}
if patch.ClearAllowedModels {
b.SetNull("allowed_models")
} else if patch.AllowedModels != nil {
modelsJSON, err := json.Marshal(*patch.AllowedModels)
if err != nil {
return fmt.Errorf("marshal allowed_models: %w", err)
}
b.Set("allowed_models", string(modelsJSON))
}
if !b.HasSets() {
return nil
@@ -75,7 +140,18 @@ func (s *GroupStore) Update(ctx context.Context, id string, name, description *s
return nil
}
// Delete removes a group. Returns an error if source=system.
func (s *GroupStore) Delete(ctx context.Context, id string) error {
var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return sql.ErrNoRows
}
return err
}
if source == "system" {
return fmt.Errorf("system groups cannot be deleted")
}
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id)
if err != nil {
return err
@@ -88,33 +164,31 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
// ── Scoped Listing ──────────────────────────
const groupSelectCols = `
g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'),
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models`
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
COALESCE(g.source, 'manual')
return queryGroups(ctx, `SELECT `+groupSelectCols+`
FROM groups g
ORDER BY g.scope, g.name`)
}
func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
COALESCE(g.source, 'manual')
return queryGroups(ctx, `SELECT `+groupSelectCols+`
FROM groups g
WHERE g.scope = 'team' AND g.team_id = ?
ORDER BY g.name`, teamID)
}
func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) {
return queryGroups(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
COALESCE(g.source, 'manual')
return queryGroups(ctx, `SELECT `+groupSelectCols+`
FROM groups g
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?)
ORDER BY g.scope, g.name`, userID)
@@ -209,12 +283,42 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
for rows.Next() {
var g models.Group
var teamID sql.NullString
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source); err != nil {
var createdBy sql.NullString
var permsStr sql.NullString
var budgetDaily, budgetMonthly sql.NullInt64
var allowedStr sql.NullString
if err := rows.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
); err != nil {
return nil, err
}
g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsStr.String)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if allowedStr.Valid && allowedStr.String != "" {
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
}
result = append(result, g)
}
return result, rows.Err()
}
func unmarshalStringSlice(s string) []string {
if s == "" {
return []string{}
}
var out []string
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []string{}
}
return out
}

View File

@@ -97,6 +97,11 @@ func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
return b
}
func (b *UpdateBuilder) SetNull(col string) *UpdateBuilder {
b.sets = append(b.sets, col+" = NULL")
return b
}
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
data, err := json.Marshal(val)
if err != nil {

View File

@@ -51,6 +51,43 @@
'<h4 id="adminGroupDetailName" style="margin:0"></h4>' +
'<span id="adminGroupDetailMeta" class="text-muted"></span>' +
'</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="adminAddGroupMemberForm" class="admin-inline-form" style="display:none">' +
'<div class="form-row">' +

View File

@@ -621,6 +621,10 @@ const API = {
},
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 ─────────────────────────
listMyGroups() { return this._get('/api/v1/groups/mine'); },

View File

@@ -916,16 +916,94 @@ Object.assign(UI, {
document.getElementById('adminGroupDetailName').textContent = groupName;
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
// Load group details for meta line
// Load group + available permissions + enabled models in parallel
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';
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);
},
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) {
const el = document.getElementById('adminGroupMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';