package auth import ( "context" "time" "switchboard-core/store" ) // EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in // migration 002. Every authenticated user receives its permissions without an // explicit membership row. const EveryoneGroupID = "00000000-0000-0000-0000-000000000001" // AdminsGroupID is the stable ID of the "Admins" system group seeded in // migration 002. Members receive surface.admin.access and all platform // permissions. Replaces the legacy users.role = 'admin' check. const AdminsGroupID = "00000000-0000-0000-0000-000000000002" // Permission constants — domain.action convention. const ( PermSurfaceAdminAccess = "surface.admin.access" // full admin panel access (replaces role check) PermExtensionUse = "extension.use" // use installed extensions PermExtensionInstall = "extension.install" // install/manage extension packages PermWorkflowCreate = "workflow.create" // create workflow definitions PermWorkflowSubmit = "workflow.submit" // submit to public workflows 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{ PermSurfaceAdminAccess, PermExtensionUse, PermExtensionInstall, PermWorkflowCreate, PermWorkflowSubmit, PermAdminView, PermTokenUnlimited, } // ── Resolution ────────────────────────────── // ResolvePermissions returns the effective permission set for a user. // Always includes the Everyone group regardless of membership. // Includes both implicit Everyone group and explicit group memberships. 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 } // SyncAdminsGroupMembership adds or removes a user from the Admins group // based on their role. Called by auth providers and admin handlers when // a user's role changes so that permissions stay in sync. func SyncAdminsGroupMembership(ctx context.Context, stores store.Stores, userID, role string) { if role == "admin" { _ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID) } else { _ = stores.Groups.RemoveMember(ctx, AdminsGroupID, 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 }