remove token budgets + allowed models from groups
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m12s
CI/CD / test-sqlite (pull_request) Successful in 2m45s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s

Provider-era cruft: token_budget_daily, token_budget_monthly, and
allowed_models columns removed from groups table (both dialects).
ResolveTokenBudget() and ResolveModelAllowlist() deleted. GroupPatch
trimmed to name/description/permissions. Admin groups UI simplified
to permissions-only. -283 lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:53:18 +00:00
parent 316c9cbfa7
commit e8e45184f7
9 changed files with 38 additions and 283 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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()