diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f71cee..86d9d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,21 @@ All notable changes to Switchboard Core are documented here. - Demotion/deletion safeguards count Admins group members instead of `CountByRole` - Kernel permissions: 6 → 7 (added `surface.admin.access`) +### Removed + +- **Token budgets** from groups: `token_budget_daily`, `token_budget_monthly` + columns, `ResolveTokenBudget()`, and all store/handler/UI code. Provider-era + cruft — token budgets belong in a future provider extension, not the kernel. +- **Allowed models** from groups: `allowed_models` column, + `ResolveModelAllowlist()`, `toggleModel` UI. Same rationale. +- `GroupPatch` fields: `ClearBudgetDaily`, `ClearBudgetMonthly`, + `ClearAllowedModels` — no longer needed +- Admin groups UI: Token Budgets section, Allowed Models section, models API call + ### Migration notes -- 002_teams.sql (both dialects): added Admins group seed with all permissions +- 002_teams.sql (both dialects): added Admins group seed, removed + `token_budget_daily`, `token_budget_monthly`, `allowed_models` columns - No new migration files — edited in place per pre-MVP policy --- diff --git a/server/auth/permissions.go b/server/auth/permissions.go index 12854d7..4748d79 100644 --- a/server/auth/permissions.go +++ b/server/auth/permissions.go @@ -2,7 +2,6 @@ package auth import ( "context" - "time" "switchboard-core/store" ) @@ -80,91 +79,3 @@ func SyncAdminsGroupMembership(ctx context.Context, stores store.Stores, userID, } } -// ── Token Budget ──────────────────────────── - -// TokenBudget holds the resolved ceiling for a user and the time window it covers. -type TokenBudget struct { - Limit int64 - Period string // "daily" or "monthly" -} - -// ResolveTokenBudget returns the most restrictive token budget across all of the -// user's groups. Returns nil if no group has a budget set (unrestricted). -// Callers should skip budget enforcement when providerScope == "personal" (BYOK). -func ResolveTokenBudget(ctx context.Context, stores store.Stores, userID string) (*TokenBudget, error) { - groups, err := stores.Groups.ListForUser(ctx, userID) - if err != nil { - return nil, err - } - - now := time.Now() - isNewDay := now.Hour() < 1 - _ = isNewDay // used by callers for cache invalidation hints if needed - - var daily, monthly *int64 - for _, g := range groups { - if g.TokenBudgetDaily != nil { - if daily == nil || *g.TokenBudgetDaily < *daily { - daily = g.TokenBudgetDaily - } - } - if g.TokenBudgetMonthly != nil { - if monthly == nil || *g.TokenBudgetMonthly < *monthly { - monthly = g.TokenBudgetMonthly - } - } - } - - // Return the most restrictive window. Daily wins when both are set - // and daily is the binding constraint. - if daily != nil { - return &TokenBudget{Limit: *daily, Period: "daily"}, nil - } - if monthly != nil { - return &TokenBudget{Limit: *monthly, Period: "monthly"}, nil - } - return nil, nil -} - -// ── Model Allowlist ───────────────────────── - -// ResolveModelAllowlist returns the union of allowed model IDs across all of the -// user's groups. Returns nil if the user is unrestricted (any group has a nil -// allowlist, which means "no restriction"). -func ResolveModelAllowlist(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) { - groups, err := stores.Groups.ListForUser(ctx, userID) - if err != nil { - return nil, err - } - - // If any group has nil AllowedModels, the user is unrestricted. - for _, g := range groups { - if g.AllowedModels == nil { - return nil, nil - } - } - - // All groups have explicit allowlists — union them. - allowed := make(map[string]bool) - for _, g := range groups { - for _, m := range g.AllowedModels { - allowed[m] = true - } - } - // No groups at all → fall through to Everyone group check. - if len(groups) == 0 { - if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil { - if everyone.AllowedModels == nil { - return nil, nil // Everyone has no restriction - } - for _, m := range everyone.AllowedModels { - allowed[m] = true - } - } - } - - if len(allowed) == 0 { - return nil, nil // empty allowlists across all groups = unrestricted - } - return allowed, nil -} diff --git a/server/database/migrations/postgres/002_teams.sql b/server/database/migrations/postgres/002_teams.sql index 71ae3e2..7e5ef1d 100644 --- a/server/database/migrations/postgres/002_teams.sql +++ b/server/database/migrations/postgres/002_teams.sql @@ -44,9 +44,6 @@ CREATE TABLE IF NOT EXISTS groups ( source VARCHAR(20) NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc', 'system')), permissions JSONB NOT NULL DEFAULT '[]'::jsonb, - token_budget_daily BIGINT, - token_budget_monthly BIGINT, - allowed_models JSONB, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT groups_scope_team CHECK ( diff --git a/server/database/migrations/sqlite/002_teams.sql b/server/database/migrations/sqlite/002_teams.sql index bd5e6ba..76fba74 100644 --- a/server/database/migrations/sqlite/002_teams.sql +++ b/server/database/migrations/sqlite/002_teams.sql @@ -40,9 +40,6 @@ CREATE TABLE IF NOT EXISTS groups ( created_by TEXT REFERENCES users(id), source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc', 'system')), permissions TEXT NOT NULL DEFAULT '[]', - token_budget_daily INTEGER, - token_budget_monthly INTEGER, - allowed_models TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); diff --git a/server/handlers/groups.go b/server/handlers/groups.go index 1cff5e0..4b7c9eb 100644 --- a/server/handlers/groups.go +++ b/server/handlers/groups.go @@ -25,15 +25,9 @@ type createGroupRequest struct { } type updateGroupRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Permissions *[]string `json:"permissions,omitempty"` - TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"` - TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"` - AllowedModels *[]string `json:"allowed_models,omitempty"` - ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"` - ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"` - ClearAllowedModels bool `json:"clear_allowed_models,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Permissions *[]string `json:"permissions,omitempty"` } type addGroupMemberRequest struct { @@ -144,15 +138,9 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) { } 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, + Name: req.Name, + Description: req.Description, + Permissions: req.Permissions, } // Validate permissions if provided diff --git a/server/models/models.go b/server/models/models.go index 23142f4..988ade3 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -316,24 +316,15 @@ type Group struct { 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 + Permissions []string `json:"permissions" db:"permissions"` + 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"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Permissions *[]string `json:"permissions,omitempty"` } // GroupMember links a user to a group. diff --git a/server/store/postgres/groups.go b/server/store/postgres/groups.go index 7a752c7..f503b84 100644 --- a/server/store/postgres/groups.go +++ b/server/store/postgres/groups.go @@ -43,17 +43,14 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err 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 + COALESCE(g.permissions, '[]'::jsonb) FROM groups g WHERE g.id = $1`, id) return scanGroup(row) } // 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. +// System groups (source=system) allow permission 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 { @@ -76,25 +73,6 @@ func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPa } 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 } @@ -138,10 +116,7 @@ const groupSelectCols = ` 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` + COALESCE(g.permissions, '[]'::jsonb)` func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) { return queryGroups(ctx, `SELECT`+groupSelectCols+` @@ -250,28 +225,17 @@ func scanGroup(row groupScanner) (*models.Group, error) { 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, + &permsJSON, ); 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 } @@ -288,28 +252,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G var teamID sql.NullString 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, + &permsJSON, ); 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() diff --git a/server/store/sqlite/groups.go b/server/store/sqlite/groups.go index 18f036d..4ebe6d5 100644 --- a/server/store/sqlite/groups.go +++ b/server/store/sqlite/groups.go @@ -48,22 +48,17 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err 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.permissions, '[]'), - g.token_budget_daily, - g.token_budget_monthly, - g.allowed_models + COALESCE(g.permissions, '[]') FROM groups g WHERE g.id = ?`, id).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, + &permsStr, ) if err != nil { return nil, err @@ -71,20 +66,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, 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 } // Update applies a patch to a group. -// system groups allow permission/budget edits but not structural field changes. +// System groups allow permission 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 { @@ -107,25 +93,6 @@ func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPa } 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 } @@ -169,10 +136,7 @@ const groupSelectCols = ` 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` + COALESCE(g.permissions, '[]')` func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) { return queryGroups(ctx, `SELECT `+groupSelectCols+` @@ -285,28 +249,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G var teamID sql.NullString 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, + &permsStr, ); 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() diff --git a/src/js/sw/surfaces/admin/groups.js b/src/js/sw/surfaces/admin/groups.js index 8adc8c2..3567a9d 100644 --- a/src/js/sw/surfaces/admin/groups.js +++ b/src/js/sw/surfaces/admin/groups.js @@ -11,7 +11,6 @@ export default function GroupsSection() { const [members, setMembers] = useState([]); const [allPerms, setAllPerms] = useState([]); const [allUsers, setAllUsers] = useState([]); - const [allModels, setAllModels] = useState([]); const [groupData, setGroupData] = useState({}); const [showCreate, setShowCreate] = useState(false); const [showAddMember, setShowAddMember] = useState(false); @@ -30,21 +29,16 @@ export default function GroupsSection() { setDetail(group); setGroupData({ permissions: group.permissions || [], - token_budget_daily: group.token_budget_daily ?? '', - token_budget_monthly: group.token_budget_monthly ?? '', - allowed_models: group.allowed_models || [], }); try { - const [m, p, u, models] = await Promise.all([ + const [m, p, u] = await Promise.all([ sw.api.admin.groups.members(group.id), sw.api.admin.permissions.list(), sw.api.admin.users.list(), - sw.api.admin.models.list().catch(() => []), ]); setMembers(m || []); setAllPerms(p.permissions || []); setAllUsers(u || []); - setAllModels(models || []); } catch (e) { sw.toast(e.message, 'error'); } } @@ -63,13 +57,10 @@ export default function GroupsSection() { } catch (e) { sw.toast(e.message, 'error'); } } - async function savePermsAndBudgets() { + async function savePermissions() { try { await sw.api.admin.groups.update(detail.id, { permissions: groupData.permissions, - token_budget_daily: groupData.token_budget_daily || null, - token_budget_monthly: groupData.token_budget_monthly || null, - allowed_models: groupData.allowed_models.length ? groupData.allowed_models : null, }); sw.toast('Saved', 'success'); } catch (e) { sw.toast(e.message, 'error'); } @@ -84,15 +75,6 @@ export default function GroupsSection() { }); } - function toggleModel(modelId) { - setGroupData(prev => { - const list = prev.allowed_models.includes(modelId) - ? prev.allowed_models.filter(m => m !== modelId) - : [...prev.allowed_models, modelId]; - return { ...prev, allowed_models: list }; - }); - } - async function addMember(e) { e.preventDefault(); const uid = e.target.userId.value; @@ -151,37 +133,8 @@ export default function GroupsSection() { -
Leave empty for unrestricted.
-