From 316c9cbfa714964c5a0fe784b3b4dec99c533f4e Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 16:14:46 +0000 Subject: [PATCH 1/3] =?UTF-8?q?admin=20=E2=86=92=20RBAC=20group=20migratio?= =?UTF-8?q?n:=20grant-based=20access=20replaces=20role=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit surface.admin.access permission + seeded Admins system group replaces hardcoded role == "admin" middleware checks. Admin bypass removed from RequirePermission — all permissions flow through group membership. Bootstrap, seed, OIDC, and admin handlers sync group membership on role changes. Demotion/deletion safeguards use group member count. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 34 +++++++++++++- ROADMAP.md | 14 ++++-- server/auth/oidc.go | 6 +++ server/auth/permissions.go | 34 ++++++++++---- .../migrations/postgres/002_teams.sql | 13 ++++++ .../database/migrations/sqlite/002_teams.sql | 12 +++++ server/database/testhelper.go | 46 +++++++++++++++++++ server/handlers/admin.go | 37 +++++++++------ server/handlers/auth.go | 15 ++++++ server/handlers/extension_test.go | 3 +- server/handlers/notification_test.go | 3 +- server/main.go | 4 +- server/middleware/admin.go | 15 ++++-- server/middleware/page_auth.go | 10 ++-- server/middleware/permissions.go | 12 ++--- 15 files changed, 208 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc9f6c..2f71cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,39 @@ All notable changes to Switchboard Core are documented here. -## [Unreleased] — v0.1.0 +## [Unreleased] — v0.2.0 + +### Added + +- **Admin → RBAC group migration**: `surface.admin.access` permission replaces + hardcoded `role == "admin"` checks. Seeded "Admins" system group carries all + platform permissions. Admin middleware now resolves grants through the standard + permission system — no special-casing. Any group can grant `surface.admin.access`. +- `AdminsGroupID` constant (`00000000-0000-0000-0000-000000000002`) +- `SyncAdminsGroupMembership()` — shared helper used by bootstrap, seed, OIDC, + and admin handlers to keep group membership in sync with role changes +- `SeedAdminsGroupMember()` test helper +- System groups re-seeded after `TruncateAll` in test helper + +### Changed + +- `RequireAdmin()` / `RequireAdminPage()` now accept `store.Stores` and check + `surface.admin.access` grant instead of `role == "admin"` +- `RequirePermission()` no longer bypasses checks for admin role — admins get + permissions through group membership like everyone else +- Bootstrap/seed/OIDC login all add admin users to Admins group +- Admin create/update/delete handlers sync Admins group membership on role change +- Demotion/deletion safeguards count Admins group members instead of `CountByRole` +- Kernel permissions: 6 → 7 (added `surface.admin.access`) + +### Migration notes + +- 002_teams.sql (both dialects): added Admins group seed with all permissions +- No new migration files — edited in place per pre-MVP policy + +--- + +## [v0.1.0] — 2026-03-26 Forked from chat-switchboard v0.38.5. Gutted to a pure extension platform. diff --git a/ROADMAP.md b/ROADMAP.md index ef3db1e..d2d7341 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,11 +36,17 @@ platform that extensions build on. The contract that extensions build against. Three trigger primitives, SDK stabilization, and the first rebuilt extension (tasks). -- **Trigger system**: time (cron), webhook (inbound HTTP), event (bus subscription) +| Step | Status | Description | +|------|--------|-------------| +| Admin → RBAC group | ✅ | `surface.admin.access` permission + Admins system group replaces `role == "admin"` checks. Admin bypass removed from permission middleware. | +| Settings cascade | 🔲 | `user_overridable` flag, RBAC scope auth, resolution chain | +| ICD (API contract) | 🔲 | OpenAPI spec from registered routes, kernel-only endpoint docs | +| Trigger system | 🔲 | Time (cron), webhook (inbound HTTP), event (bus subscription) | +| SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI | + +### Remaining v0.2.0 features + - **Event bus subscriptions**: extensions register match expressions at install time -- **SDK contract**: `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage` -- **Primitive UI components**: toast, confirm, prompt, dialog (stabilize existing) -- **Theme tokens** exposed to extensions - **Task extension**: first proof-of-concept — full task system rebuilt as a Starlark extension using triggers + ext_data + notifications - **Settings override model**: RBAC controls scope auth (admin → global, diff --git a/server/auth/oidc.go b/server/auth/oidc.go index b7d73a8..c777293 100644 --- a/server/auth/oidc.go +++ b/server/auth/oidc.go @@ -171,6 +171,9 @@ func (p *OIDCProvider) Authenticate(c *gin.Context, stores store.Stores) (*Resul user.Role = role } + // Sync Admins group membership with resolved role + SyncAdminsGroupMembership(ctx, stores, user.ID, role) + // Sync groups p.syncGroups(ctx, user.ID, claims, stores) @@ -474,6 +477,9 @@ func (p *OIDCProvider) autoProvision( log.Printf("[auth/oidc] auto-provisioned user %s (sub=%s, email=%s)", user.ID, sub, email) + // Sync Admins group membership with resolved role + SyncAdminsGroupMembership(ctx, stores, user.ID, role) + // Auto-add to default team if p.cfg.DefaultTeam != "" { if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil { diff --git a/server/auth/permissions.go b/server/auth/permissions.go index 1543e4d..12854d7 100644 --- a/server/auth/permissions.go +++ b/server/auth/permissions.go @@ -8,23 +8,30 @@ import ( ) // EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in -// migration 020. Every authenticated user receives its permissions without an +// 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 ( - 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 + 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, @@ -37,7 +44,7 @@ var AllPermissions = []string{ // 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. +// 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) @@ -62,6 +69,17 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) 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. diff --git a/server/database/migrations/postgres/002_teams.sql b/server/database/migrations/postgres/002_teams.sql index 8dc6e9c..71ae3e2 100644 --- a/server/database/migrations/postgres/002_teams.sql +++ b/server/database/migrations/postgres/002_teams.sql @@ -83,3 +83,16 @@ VALUES ( '["extension.use","workflow.submit"]'::jsonb ) ON CONFLICT (id) DO NOTHING; + +-- Seed Admins group — members receive full platform permissions. +-- Replaces the legacy users.role = 'admin' authorization check. +-- Membership is managed by bootstrap/seed logic at runtime. +INSERT INTO groups (id, name, description, scope, created_by, source, permissions) +VALUES ( + '00000000-0000-0000-0000-000000000002', + 'Admins', + 'Full platform access — replaces legacy admin role.', + 'global', NULL, 'system', + '["surface.admin.access","extension.use","extension.install","workflow.create","workflow.submit","admin.view","token.unlimited"]'::jsonb +) +ON CONFLICT (id) DO NOTHING; diff --git a/server/database/migrations/sqlite/002_teams.sql b/server/database/migrations/sqlite/002_teams.sql index e10f3a6..bd5e6ba 100644 --- a/server/database/migrations/sqlite/002_teams.sql +++ b/server/database/migrations/sqlite/002_teams.sql @@ -74,3 +74,15 @@ VALUES ( 'global', NULL, 'system', '["extension.use","workflow.submit"]' ); + +-- Seed Admins group — members receive full platform permissions. +-- Replaces the legacy users.role = 'admin' authorization check. +-- Membership is managed by bootstrap/seed logic at runtime. +INSERT OR IGNORE INTO groups (id, name, description, scope, created_by, source, permissions) +VALUES ( + '00000000-0000-0000-0000-000000000002', + 'Admins', + 'Full platform access — replaces legacy admin role.', + 'global', NULL, 'system', + '["surface.admin.access","extension.use","extension.install","workflow.create","workflow.submit","admin.view","token.unlimited"]' +); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index cd8a3ff..06c24fc 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -304,6 +304,31 @@ func TruncateAll(t *testing.T) { } } + // Re-seed system groups (truncated above). + if IsSQLite() { + DB.Exec(`INSERT OR IGNORE 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', '["extension.use","workflow.submit"]')`) + DB.Exec(`INSERT OR IGNORE INTO groups (id, name, description, scope, created_by, source, permissions) + VALUES ('00000000-0000-0000-0000-000000000002', 'Admins', + 'Full platform access — replaces legacy admin role.', + 'global', NULL, 'system', + '["surface.admin.access","extension.use","extension.install","workflow.create","workflow.submit","admin.view","token.unlimited"]')`) + } else { + DB.Exec(`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', '["extension.use","workflow.submit"]'::jsonb) + ON CONFLICT (id) DO NOTHING`) + DB.Exec(`INSERT INTO groups (id, name, description, scope, created_by, source, permissions) + VALUES ('00000000-0000-0000-0000-000000000002', 'Admins', + 'Full platform access — replaces legacy admin role.', + 'global', NULL, 'system', + '["surface.admin.access","extension.use","extension.install","workflow.create","workflow.submit","admin.view","token.unlimited"]'::jsonb) + ON CONFLICT (id) DO NOTHING`) + } + // Re-seed default config rows. if IsSQLite() { DB.Exec(` @@ -377,6 +402,27 @@ func SeedTestUser(t *testing.T, username, email string) string { return id } +// SeedAdminsGroupMember adds a user to the Admins system group in test databases. +// Call after creating a user with role='admin' so they receive surface.admin.access +// through the normal permission resolution path. +func SeedAdminsGroupMember(t *testing.T, userID string) { + t.Helper() + const adminsGroupID = "00000000-0000-0000-0000-000000000002" + if IsSQLite() { + _, err := DB.Exec(`INSERT OR IGNORE INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`, + uuid.New().String(), adminsGroupID, userID, userID) + if err != nil { + t.Fatalf("SeedAdminsGroupMember: %v", err) + } + return + } + _, err := DB.Exec(`INSERT INTO group_members (group_id, user_id, added_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, + adminsGroupID, userID, userID) + if err != nil { + t.Fatalf("SeedAdminsGroupMember: %v", err) + } +} + // SeedTestChannel creates a test channel owned by userID and returns the channel ID. func SeedTestChannel(t *testing.T, userID, title string) string { t.Helper() diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 420e7fb..2aa8665 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -100,12 +100,16 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { return } + // Sync Admins group membership with assigned role. + auth.SyncAdminsGroupMembership(c.Request.Context(), h.stores, user.ID, role) + h.auditLog(c, "user.create", "user", user.ID, nil) c.JSON(http.StatusCreated, user) } func (h *AdminHandler) UpdateUserRole(c *gin.Context) { id := c.Param("id") + ctx := c.Request.Context() var req struct { Role string `json:"role" binding:"required"` } @@ -114,27 +118,30 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) { return } - // Guard: prevent demoting the last admin - if req.Role != models.UserRoleAdmin { - user, err := h.stores.Users.GetByID(c.Request.Context(), id) - if err != nil || user == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + user, err := h.stores.Users.GetByID(ctx, id) + if err != nil || user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + // Guard: prevent demoting the last admin. + // Count Admins group members — the source of truth for admin access. + if req.Role != models.UserRoleAdmin && user.Role == models.UserRoleAdmin { + members, _ := h.stores.Groups.ListMembers(ctx, auth.AdminsGroupID) + if len(members) <= 1 { + c.JSON(http.StatusConflict, gin.H{"error": "cannot demote the last admin"}) return } - if user.Role == models.UserRoleAdmin { - adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin) - if adminCount <= 1 { - c.JSON(http.StatusConflict, gin.H{"error": "cannot demote the last admin"}) - return - } - } } - if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil { + if err := h.stores.Users.Update(ctx, id, map[string]interface{}{"role": req.Role}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"}) return } + // Sync Admins group membership with role change. + auth.SyncAdminsGroupMembership(ctx, h.stores, id, req.Role) + h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role}) if h.onUserChanged != nil { h.onUserChanged(id) @@ -254,8 +261,8 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { return } if user.Role == models.UserRoleAdmin { - adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin) - if adminCount <= 1 { + members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID) + if len(members) <= 1 { c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"}) return } diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 9b40eb8..313e428 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -507,6 +507,7 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC cache = uekCache[0] } ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache) + ensureAdminsGroupMember(ctx, s, existing.ID) log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername) return } @@ -531,6 +532,7 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC log.Printf("⚠ Failed to create admin user: %v", err) return } + ensureAdminsGroupMember(ctx, s, user.ID) log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername) } @@ -601,6 +603,9 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) cache = uekCache[0] } ProbeAndRepairVault(ctx, s, existing.ID, password, cache) + if role == models.UserRoleAdmin { + ensureAdminsGroupMember(ctx, s, existing.ID) + } log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role) continue } @@ -620,10 +625,20 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) log.Printf("⚠ Seed user '%s' failed: %v", username, err) continue } + if role == models.UserRoleAdmin { + ensureAdminsGroupMember(ctx, s, user.ID) + } log.Printf(" 🌱 Seed user '%s' created (role=%s, active=true)", username, role) } } +// ensureAdminsGroupMember adds userID to the Admins system group (idempotent). +// Used by BootstrapAdmin and SeedUsers so that admin users receive +// surface.admin.access through the normal permission resolution path. +func ensureAdminsGroupMember(ctx context.Context, s store.Stores, userID string) { + auth.SyncAdminsGroupMembership(ctx, s, userID, models.UserRoleAdmin) +} + // IsRegistrationEnabled checks the platform policy. func IsRegistrationEnabled(s store.Stores) bool { val, _ := s.Policies.GetBool(context.Background(), "allow_registration") diff --git a/server/handlers/extension_test.go b/server/handlers/extension_test.go index 97a174b..8ee89c8 100644 --- a/server/handlers/extension_test.go +++ b/server/handlers/extension_test.go @@ -73,7 +73,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness { // Admin extension endpoints admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin()) + admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores)) admin.GET("/extensions", extH.AdminListExtensions) admin.POST("/extensions", extH.AdminInstallExtension) admin.PUT("/extensions/:id", extH.AdminUpdateExtension) @@ -84,6 +84,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness { `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, "ext-admin", "ext-admin@test.com", "$2a$10$test", "admin", "ext-admin", "builtin", ) + database.SeedAdminsGroupMember(t, adminID) adminToken := makeToken(adminID, "ext-admin@test.com", "admin") // Seed regular user diff --git a/server/handlers/notification_test.go b/server/handlers/notification_test.go index 14e54d4..fb3a358 100644 --- a/server/handlers/notification_test.go +++ b/server/handlers/notification_test.go @@ -65,7 +65,7 @@ func setupNotifHarness(t *testing.T) *notifHarness { // Admin broadcast route (v0.28.6) admin := api.Group("/admin") admin.Use(middleware.Auth(cfg, stores.Users, userCache)) - admin.Use(middleware.RequireAdmin()) + admin.Use(middleware.RequireAdmin(stores)) admin.POST("/notifications/broadcast", notifH.Broadcast) // Set up notification service singleton for Broadcast handler @@ -75,6 +75,7 @@ func setupNotifHarness(t *testing.T) *notifHarness { // Seed admin adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID) + database.SeedAdminsGroupMember(t, adminID) adminToken := makeToken(adminID, "notif-admin@test.com", "admin") // Seed regular user diff --git a/server/main.go b/server/main.go index d74dade..2e935dc 100644 --- a/server/main.go +++ b/server/main.go @@ -513,7 +513,7 @@ func main() { // ── Admin routes ──────────────────────── admin := api.Group("/admin") admin.Use(middleware.Auth(cfg, stores.Users, userCache)) - admin.Use(middleware.RequireAdmin()) + admin.Use(middleware.RequireAdmin(stores)) admin.Use(middleware.ValidatePathParams()) { adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore) @@ -693,7 +693,7 @@ func main() { Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache), Admin: []gin.HandlerFunc{ middleware.AuthOrRedirect(cfg, stores.Users, userCache), - middleware.RequireAdminPage(), + middleware.RequireAdminPage(stores), }, Session: middleware.AuthOrRedirect(cfg, stores.Users, userCache), // TODO: session middleware for workflow visitors (v0.2.0) }) diff --git a/server/middleware/admin.go b/server/middleware/admin.go index 1fc7b32..60b4793 100644 --- a/server/middleware/admin.go +++ b/server/middleware/admin.go @@ -4,14 +4,19 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "switchboard-core/auth" + "switchboard-core/store" ) -// RequireAdmin returns middleware that restricts access to admin users. -// Must be used after Auth() middleware which sets "role" in context. -func RequireAdmin() gin.HandlerFunc { +// RequireAdmin returns middleware that restricts access to users with the +// surface.admin.access permission (i.e. members of the Admins group). +// Must be used after Auth() middleware which sets "user_id" in context. +func RequireAdmin(stores store.Stores) gin.HandlerFunc { return func(c *gin.Context) { - role, exists := c.Get("role") - if !exists || role != "admin" { + userID := c.GetString("user_id") + perms, err := resolveAndCachePerms(c, stores, userID) + if err != nil || !perms[auth.PermSurfaceAdminAccess] { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "admin access required", }) diff --git a/server/middleware/page_auth.go b/server/middleware/page_auth.go index b4f27f7..1c578f0 100644 --- a/server/middleware/page_auth.go +++ b/server/middleware/page_auth.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "switchboard-core/auth" "switchboard-core/config" "switchboard-core/database" "switchboard-core/store" @@ -85,12 +86,13 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus } } -// RequireAdminPage aborts with 403 if the user isn't an admin. +// RequireAdminPage aborts with 403 if the user lacks surface.admin.access. // Use after AuthOrRedirect for admin-only page routes. -func RequireAdminPage() gin.HandlerFunc { +func RequireAdminPage(stores store.Stores) gin.HandlerFunc { return func(c *gin.Context) { - role, _ := c.Get("role") - if role != "admin" { + userID := c.GetString("user_id") + perms, err := resolveAndCachePerms(c, stores, userID) + if err != nil || !perms[auth.PermSurfaceAdminAccess] { c.String(http.StatusForbidden, "Admin access required") c.Abort() return diff --git a/server/middleware/permissions.go b/server/middleware/permissions.go index 777cfd2..1d151f8 100644 --- a/server/middleware/permissions.go +++ b/server/middleware/permissions.go @@ -12,17 +12,11 @@ import ( 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. +// Permission resolution is cached in the request context — computed at most +// once per request regardless of how many RequirePermission middlewares are +// chained. Admins receive permissions through the Admins group. 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] { -- 2.49.1 From e8e45184f78939db0ef30c0bd8b1e819ec7721a0 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 16:53:18 +0000 Subject: [PATCH 2/3] remove token budgets + allowed models from groups 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) --- CHANGELOG.md | 14 ++- server/auth/permissions.go | 89 ------------------- .../migrations/postgres/002_teams.sql | 3 - .../database/migrations/sqlite/002_teams.sql | 3 - server/handlers/groups.go | 24 ++--- server/models/models.go | 19 ++-- server/store/postgres/groups.go | 59 ++---------- server/store/sqlite/groups.go | 57 ++---------- src/js/sw/surfaces/admin/groups.js | 53 +---------- 9 files changed, 38 insertions(+), 283 deletions(-) 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() { -
-
Token Budgets
-
-
- setGroupData(p => ({ ...p, token_budget_daily: e.target.value ? Number(e.target.value) : '' }))} /> -
-
- setGroupData(p => ({ ...p, token_budget_monthly: e.target.value ? Number(e.target.value) : '' }))} /> -
-
-
- -
-
Allowed Models
-

Leave empty for unrestricted.

-
- ${allModels.map(m => html` - - `)} -
-
-
- +

-- 2.49.1 From b10f5bee0533a8065ad390fbd84604c799ec4658 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 17:23:12 +0000 Subject: [PATCH 3/3] drop users.role column: full RBAC through group membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role column was a pre-RBAC artifact. All authorization now flows through explicit group membership and permission grants: - Everyone group: all users added on creation (no implicit membership) - Admins group: grants surface.admin.access + all platform permissions - JWT claims, login response, profile: role field removed - OIDC: isIdPAdmin() maps IdP claims → Admins group (no role writes) - Admin UI: role dropdown removed, admin managed through groups - Middleware cache simplified to isActive only 28 files changed, -79 lines net. Zero magic roles. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 57 +++++++++------ server/auth/builtin.go | 3 +- server/auth/mtls.go | 6 +- server/auth/mtls_test.go | 7 -- server/auth/oidc.go | 31 +++----- server/auth/permissions.go | 34 ++++----- server/config/config.go | 4 -- .../database/migrations/postgres/001_core.sql | 2 - .../database/migrations/sqlite/001_core.sql | 1 - server/database/testhelper.go | 25 +++++-- server/handlers/admin.go | 72 ++++++++++--------- server/handlers/auth.go | 50 +++++-------- server/handlers/auth_test.go | 6 -- server/handlers/extension_test.go | 10 +-- server/handlers/notification_test.go | 2 +- server/handlers/profile_bootstrap.go | 25 +++---- server/handlers/settings.go | 2 - server/handlers/test_helpers_test.go | 3 +- server/main.go | 2 - server/middleware/auth.go | 56 ++++++--------- server/middleware/page_auth.go | 8 +-- server/models/models.go | 6 +- server/notifications/sources.go | 14 ++-- server/store/interfaces.go | 3 - server/store/postgres/user.go | 21 ++---- server/store/sqlite/user.go | 21 ++---- src/js/sw/sdk/api-domains.js | 2 +- src/js/sw/surfaces/admin/users.js | 18 ++--- 28 files changed, 206 insertions(+), 285 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d9d4e..55b74f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,40 +6,51 @@ All notable changes to Switchboard Core are documented here. ### Added -- **Admin → RBAC group migration**: `surface.admin.access` permission replaces - hardcoded `role == "admin"` checks. Seeded "Admins" system group carries all - platform permissions. Admin middleware now resolves grants through the standard - permission system — no special-casing. Any group can grant `surface.admin.access`. -- `AdminsGroupID` constant (`00000000-0000-0000-0000-000000000002`) -- `SyncAdminsGroupMembership()` — shared helper used by bootstrap, seed, OIDC, - and admin handlers to keep group membership in sync with role changes -- `SeedAdminsGroupMember()` test helper +- **Full RBAC**: all authorization flows through group membership and permission + grants. No magic roles, no implicit group membership, no special-casing. +- `surface.admin.access` permission — any group can grant admin panel access +- Admins system group seeded with all platform permissions +- Everyone system group — all users explicitly added on creation +- `EnsureEveryoneGroup()`, `AddToAdminsGroup()`, `RemoveFromAdminsGroup()` helpers +- `SeedAdminsGroupMember()`, `SeedEveryoneGroupMember()` test helpers - System groups re-seeded after `TruncateAll` in test helper +- OIDC `isIdPAdmin()` — maps IdP role claims to Admins group membership ### Changed -- `RequireAdmin()` / `RequireAdminPage()` now accept `store.Stores` and check - `surface.admin.access` grant instead of `role == "admin"` -- `RequirePermission()` no longer bypasses checks for admin role — admins get - permissions through group membership like everyone else -- Bootstrap/seed/OIDC login all add admin users to Admins group -- Admin create/update/delete handlers sync Admins group membership on role change -- Demotion/deletion safeguards count Admins group members instead of `CountByRole` +- `RequireAdmin()` / `RequireAdminPage()` check `surface.admin.access` grant +- `RequirePermission()` no longer bypasses for admin role +- `ResolvePermissions()` unions explicit group memberships only (no implicit Everyone) +- All user creation paths (builtin, OIDC, mTLS, admin, bootstrap, seed) add to + Everyone group. Admin users added to Admins group. +- JWT claims no longer include `role` field +- Login response no longer includes `role` in user object +- Profile endpoint no longer returns `role` +- Profile bootstrap resolves permissions from groups (no admin shortcut) +- Middleware auth cache tracks `isActive` only (no role) +- Admin create user accepts `is_admin` bool (not role string) +- Admin update role endpoint accepts `is_admin` bool, manages Admins group directly +- Demotion/deletion safeguards check Admins group member count +- Notifications `RoleFallbackHandler` queries Admins group members +- OIDC syncs Admins group on login (no role column writes) - Kernel permissions: 6 → 7 (added `surface.admin.access`) +- Admin users UI: role dropdown removed, admin managed through groups ### 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 +- **`users.role` column** — dropped from schema, model, JWT, all handlers +- `UserRoleAdmin`, `UserRoleUser` constants +- `CountByRole()` store method +- `DefaultRole` config for OIDC and mTLS providers +- `SyncAdminsGroupMembership()` (replaced by `AddToAdminsGroup`/`RemoveFromAdminsGroup`) +- OIDC `resolveRole()` (replaced by `isIdPAdmin()`) +- **Token budgets** from groups: columns, `ResolveTokenBudget()`, all store/handler/UI +- **Allowed models** from groups: column, `ResolveModelAllowlist()`, UI +- Admin groups UI: Token Budgets section, Allowed Models section ### Migration notes +- 001_core.sql (both dialects): removed `role` column from users table - 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/builtin.go b/server/auth/builtin.go index ccfd1f1..a5ae52a 100644 --- a/server/auth/builtin.go +++ b/server/auth/builtin.go @@ -82,7 +82,6 @@ func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result Username: strings.ToLower(req.Username), Email: strings.ToLower(req.Email), PasswordHash: string(hash), - Role: models.UserRoleUser, IsActive: defaultActive, AuthSource: string(ModeBuiltin), Handle: handle, @@ -92,6 +91,8 @@ func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result return nil, err } + EnsureEveryoneGroup(ctx, stores, user.ID) + return &Result{User: user, IsNewUser: true, VaultHint: req.Password}, nil } diff --git a/server/auth/mtls.go b/server/auth/mtls.go index 993e6b1..9fa4092 100644 --- a/server/auth/mtls.go +++ b/server/auth/mtls.go @@ -19,7 +19,6 @@ type MTLSConfig struct { HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint") AutoActivate bool // auto-activate new users (default true) DefaultTeam string // team ID for auto-provisioned users (optional) - DefaultRole string // "user" (default) or "admin" } // MTLSProvider authenticates via client certificate headers injected @@ -48,9 +47,6 @@ func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider { if cfg.HeaderFingerprint == "" { cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint" } - if cfg.DefaultRole == "" { - cfg.DefaultRole = models.UserRoleUser - } return &MTLSProvider{cfg: cfg} } @@ -134,7 +130,6 @@ func (p *MTLSProvider) autoProvision( Username: handle, Email: email, DisplayName: cn, - Role: p.cfg.DefaultRole, IsActive: true, AuthSource: string(ModeMTLS), ExternalID: &fingerprint, @@ -146,6 +141,7 @@ func (p *MTLSProvider) autoProvision( } log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn) + EnsureEveryoneGroup(ctx, stores, user.ID) // Auto-add to default team if configured if p.cfg.DefaultTeam != "" { diff --git a/server/auth/mtls_test.go b/server/auth/mtls_test.go index 423f230..253f17a 100644 --- a/server/auth/mtls_test.go +++ b/server/auth/mtls_test.go @@ -96,24 +96,17 @@ func TestMTLSConfig_Defaults(t *testing.T) { if p.cfg.HeaderFingerprint != "X-SSL-Client-Fingerprint" { t.Errorf("HeaderFingerprint = %q, want X-SSL-Client-Fingerprint", p.cfg.HeaderFingerprint) } - if p.cfg.DefaultRole != "user" { - t.Errorf("DefaultRole = %q, want user", p.cfg.DefaultRole) - } } func TestMTLSConfig_Custom(t *testing.T) { p := NewMTLSProvider(MTLSConfig{ HeaderDN: "X-Client-Cert-DN", HeaderVerify: "X-Client-Cert-Verify", - DefaultRole: "admin", }) if p.cfg.HeaderDN != "X-Client-Cert-DN" { t.Errorf("HeaderDN = %q, want X-Client-Cert-DN", p.cfg.HeaderDN) } - if p.cfg.DefaultRole != "admin" { - t.Errorf("DefaultRole = %q, want admin", p.cfg.DefaultRole) - } } func TestMTLSProvider_Mode(t *testing.T) { diff --git a/server/auth/oidc.go b/server/auth/oidc.go index c777293..807d3f5 100644 --- a/server/auth/oidc.go +++ b/server/auth/oidc.go @@ -31,7 +31,6 @@ type OIDCConfig struct { RedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback" AutoActivate bool // auto-activate new users (default true) DefaultTeam string // team ID for auto-provisioned users (optional) - DefaultRole string // "user" (default) or "admin" RolesClaim string // JWT claim for role mapping (default "realm_access.roles") GroupsClaim string // JWT claim for group sync (default "groups") AdminRole string // IdP role value that maps to admin (default "admin") @@ -92,9 +91,6 @@ func NewOIDCProvider(cfg OIDCConfig) (*OIDCProvider, error) { if cfg.ClientID == "" { return nil, fmt.Errorf("OIDC_CLIENT_ID is required") } - if cfg.DefaultRole == "" { - cfg.DefaultRole = models.UserRoleUser - } if cfg.RolesClaim == "" { cfg.RolesClaim = "realm_access.roles" } @@ -164,16 +160,11 @@ func (p *OIDCProvider) Authenticate(c *gin.Context, stores store.Stores) (*Resul return nil, ErrInactive } - // Re-evaluate role on every login - role := p.resolveRole(claims) - if role != user.Role { - stores.Users.Update(ctx, user.ID, map[string]interface{}{"role": role}) - user.Role = role + // Sync Admins group based on IdP role claim + if p.isIdPAdmin(claims) { + AddToAdminsGroup(ctx, stores, user.ID) } - // Sync Admins group membership with resolved role - SyncAdminsGroupMembership(ctx, stores, user.ID, role) - // Sync groups p.syncGroups(ctx, user.ID, claims, stores) @@ -313,14 +304,14 @@ func (p *OIDCProvider) validateToken(tokenString string) (*oidcClaims, error) { return claims, nil } -func (p *OIDCProvider) resolveRole(claims *oidcClaims) string { - // Check realm_access.roles for admin role +// isIdPAdmin checks whether the OIDC claims include the configured admin role. +func (p *OIDCProvider) isIdPAdmin(claims *oidcClaims) bool { for _, role := range claims.RealmAccess.Roles { if role == p.cfg.AdminRole { - return models.UserRoleAdmin + return true } } - return p.cfg.DefaultRole + return false } // syncGroups compares IdP groups claim with internal group memberships @@ -457,14 +448,12 @@ func (p *OIDCProvider) autoProvision( displayName = username } - role := p.resolveRole(claims) handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username)) user := &models.User{ Username: strings.ToLower(username), Email: strings.ToLower(email), DisplayName: displayName, - Role: role, IsActive: true, AuthSource: string(ModeOIDC), ExternalID: &sub, @@ -477,8 +466,10 @@ func (p *OIDCProvider) autoProvision( log.Printf("[auth/oidc] auto-provisioned user %s (sub=%s, email=%s)", user.ID, sub, email) - // Sync Admins group membership with resolved role - SyncAdminsGroupMembership(ctx, stores, user.ID, role) + EnsureEveryoneGroup(ctx, stores, user.ID) + if p.isIdPAdmin(claims) { + AddToAdminsGroup(ctx, stores, user.ID) + } // Auto-add to default team if p.cfg.DefaultTeam != "" { diff --git a/server/auth/permissions.go b/server/auth/permissions.go index 4748d79..60c0ebd 100644 --- a/server/auth/permissions.go +++ b/server/auth/permissions.go @@ -42,19 +42,10 @@ var AllPermissions = []string{ // ── 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. +// Unions permissions from all groups the user is a member of (including Everyone). 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 @@ -68,14 +59,19 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) 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) - } +// EnsureEveryoneGroup adds a user to the Everyone group (idempotent). +// Called on every user creation path so that Everyone membership is explicit. +func EnsureEveryoneGroup(ctx context.Context, stores store.Stores, userID string) { + _ = stores.Groups.AddMember(ctx, EveryoneGroupID, userID, userID) +} + +// AddToAdminsGroup adds a user to the Admins group (idempotent). +func AddToAdminsGroup(ctx context.Context, stores store.Stores, userID string) { + _ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID) +} + +// RemoveFromAdminsGroup removes a user from the Admins group. +func RemoveFromAdminsGroup(ctx context.Context, stores store.Stores, userID string) { + _ = stores.Groups.RemoveMember(ctx, AdminsGroupID, userID) } diff --git a/server/config/config.go b/server/config/config.go index a02ab78..ab67e5c 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -62,7 +62,6 @@ type Config struct { MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint" MTLSAutoActivate bool // auto-activate new users (default true) MTLSDefaultTeam string // team ID for auto-provisioned users (optional) - MTLSDefaultRole string // "user" (default) or "admin" // OIDC (v0.24.1) OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard" @@ -72,7 +71,6 @@ type Config struct { OIDCRedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback" OIDCAutoActivate bool // auto-activate new users (default true) OIDCDefaultTeam string // team ID for auto-provisioned users (optional) - OIDCDefaultRole string // "user" (default) or "admin" OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles") OIDCGroupsClaim string // JWT claim for group sync (default "groups") OIDCAdminRole string // IdP role value that maps to admin (default "admin") @@ -118,7 +116,6 @@ func Load() *Config { MTLSHeaderFingerprint: getEnv("MTLS_HEADER_FINGERPRINT", "X-SSL-Client-Fingerprint"), MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true), MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""), - MTLSDefaultRole: getEnv("MTLS_DEFAULT_ROLE", "user"), // OIDC OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""), @@ -128,7 +125,6 @@ func Load() *Config { OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""), OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true), OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""), - OIDCDefaultRole: getEnv("OIDC_DEFAULT_ROLE", "user"), OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"), OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"), OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"), diff --git a/server/database/migrations/postgres/001_core.sql b/server/database/migrations/postgres/001_core.sql index e96f418..0c4a924 100644 --- a/server/database/migrations/postgres/001_core.sql +++ b/server/database/migrations/postgres/001_core.sql @@ -23,8 +23,6 @@ CREATE TABLE IF NOT EXISTS users ( password_hash TEXT, display_name VARCHAR(100), avatar_url TEXT, - role VARCHAR(20) DEFAULT 'user' - CHECK (role IN ('user', 'admin')), is_active BOOLEAN DEFAULT true, settings JSONB DEFAULT '{}'::jsonb, auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin' diff --git a/server/database/migrations/sqlite/001_core.sql b/server/database/migrations/sqlite/001_core.sql index 01055bf..7eb52e5 100644 --- a/server/database/migrations/sqlite/001_core.sql +++ b/server/database/migrations/sqlite/001_core.sql @@ -12,7 +12,6 @@ CREATE TABLE IF NOT EXISTS users ( password_hash TEXT, display_name TEXT, avatar_url TEXT, - role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin')), is_active INTEGER DEFAULT 1, settings TEXT DEFAULT '{}', auth_source TEXT NOT NULL DEFAULT 'builtin', diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 06c24fc..d57e2fe 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -375,33 +375,48 @@ func TruncateAll(t *testing.T) { } } -// SeedTestUser creates a test user and returns the user ID. +// SeedTestUser creates a test user, adds to Everyone group, and returns the user ID. func SeedTestUser(t *testing.T, username, email string) string { t.Helper() handle := strings.ToLower(strings.ReplaceAll(username, " ", "-")) if IsSQLite() { id := uuid.New().String() _, err := DB.Exec(` - INSERT INTO users (id, username, email, password_hash, role, auth_source, handle) - VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', ?) + INSERT INTO users (id, username, email, password_hash, auth_source, handle) + VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', ?) `, id, username, email, handle) if err != nil { t.Fatalf("SeedTestUser: %v", err) } + SeedEveryoneGroupMember(t, id) return id } var id string err := DB.QueryRow(` - INSERT INTO users (username, email, password_hash, role, auth_source, handle) - VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', $3) + INSERT INTO users (username, email, password_hash, auth_source, handle) + VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', $3) RETURNING id `, username, email, handle).Scan(&id) if err != nil { t.Fatalf("SeedTestUser: %v", err) } + SeedEveryoneGroupMember(t, id) return id } +// SeedEveryoneGroupMember adds a user to the Everyone system group in test databases. +func SeedEveryoneGroupMember(t *testing.T, userID string) { + t.Helper() + const everyoneGroupID = "00000000-0000-0000-0000-000000000001" + if IsSQLite() { + DB.Exec(`INSERT OR IGNORE INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`, + uuid.New().String(), everyoneGroupID, userID, userID) + return + } + DB.Exec(`INSERT INTO group_members (group_id, user_id, added_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, + everyoneGroupID, userID, userID) +} + // SeedAdminsGroupMember adds a user to the Admins system group in test databases. // Call after creating a user with role='admin' so they receive surface.admin.access // through the normal permission resolution path. diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 2aa8665..8f4168c 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -69,29 +69,25 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { Username string `json:"username" binding:"required"` Email string `json:"email" binding:"required"` Password string `json:"password" binding:"required,min=8"` - Role string `json:"role"` + IsAdmin bool `json:"is_admin"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + ctx := c.Request.Context() hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) - role := models.UserRoleUser - if req.Role == models.UserRoleAdmin { - role = models.UserRoleAdmin - } user := &models.User{ Username: strings.ToLower(req.Username), Email: strings.ToLower(req.Email), PasswordHash: string(hash), - Role: role, IsActive: true, - Handle: auth.UniqueHandle(c.Request.Context(), h.stores.Users, models.HandleFromName(req.Username)), + Handle: auth.UniqueHandle(ctx, h.stores.Users, models.HandleFromName(req.Username)), } - if err := h.stores.Users.Create(c.Request.Context(), user); err != nil { + if err := h.stores.Users.Create(ctx, user); err != nil { if database.IsUniqueViolation(err) { c.JSON(http.StatusConflict, gin.H{"error": "username or email already exists"}) return @@ -100,8 +96,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { return } - // Sync Admins group membership with assigned role. - auth.SyncAdminsGroupMembership(c.Request.Context(), h.stores, user.ID, role) + auth.EnsureEveryoneGroup(ctx, h.stores, user.ID) + if req.IsAdmin { + auth.AddToAdminsGroup(ctx, h.stores, user.ID) + } h.auditLog(c, "user.create", "user", user.ID, nil) c.JSON(http.StatusCreated, user) @@ -111,42 +109,42 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) { id := c.Param("id") ctx := c.Request.Context() var req struct { - Role string `json:"role" binding:"required"` + IsAdmin bool `json:"is_admin"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - user, err := h.stores.Users.GetByID(ctx, id) - if err != nil || user == nil { + if _, err := h.stores.Users.GetByID(ctx, id); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } - // Guard: prevent demoting the last admin. - // Count Admins group members — the source of truth for admin access. - if req.Role != models.UserRoleAdmin && user.Role == models.UserRoleAdmin { + if !req.IsAdmin { + // Guard: prevent removing the last admin from Admins group. members, _ := h.stores.Groups.ListMembers(ctx, auth.AdminsGroupID) - if len(members) <= 1 { - c.JSON(http.StatusConflict, gin.H{"error": "cannot demote the last admin"}) + isMember := false + for _, m := range members { + if m.UserID == id { + isMember = true + break + } + } + if isMember && len(members) <= 1 { + c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last admin"}) return } + auth.RemoveFromAdminsGroup(ctx, h.stores, id) + } else { + auth.AddToAdminsGroup(ctx, h.stores, id) } - if err := h.stores.Users.Update(ctx, id, map[string]interface{}{"role": req.Role}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"}) - return - } - - // Sync Admins group membership with role change. - auth.SyncAdminsGroupMembership(ctx, h.stores, id, req.Role) - - h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role}) + h.auditLog(c, "user.role_change", "user", id, gin.H{"is_admin": req.IsAdmin}) if h.onUserChanged != nil { h.onUserChanged(id) } - c.JSON(http.StatusOK, gin.H{"message": "role updated"}) + c.JSON(http.StatusOK, gin.H{"message": "admin status updated"}) } func (h *AdminHandler) ToggleUserActive(c *gin.Context) { @@ -255,18 +253,22 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { id := c.Param("id") // Guard: prevent deleting the last admin - user, err := h.stores.Users.GetByID(c.Request.Context(), id) - if err != nil || user == nil { + if _, err := h.stores.Users.GetByID(c.Request.Context(), id); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } - if user.Role == models.UserRoleAdmin { - members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID) - if len(members) <= 1 { - c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"}) - return + members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID) + isAdmin := false + for _, m := range members { + if m.UserID == id { + isAdmin = true + break } } + if isAdmin && len(members) <= 1 { + c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"}) + return + } // Destroy vault and personal BYOK keys before deleting the user row h.destroyVault(c, id) diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 313e428..2b46a46 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -32,7 +32,6 @@ const bcryptCost = 12 type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` - Role string `json:"role"` jwt.RegisteredClaims } @@ -295,7 +294,6 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) { accessClaims := Claims{ UserID: user.ID, Email: user.Email, - Role: user.Role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -327,7 +325,6 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) { "username": user.Username, "email": user.Email, "display_name": user.DisplayName, - "role": user.Role, "handle": user.Handle, "auth_source": user.AuthSource, }, @@ -489,10 +486,9 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername) if existing != nil { - // Update password and ensure admin role + // Update password s.Users.Update(ctx, existing.ID, map[string]interface{}{ "password_hash": string(hash), - "role": models.UserRoleAdmin, "is_active": true, }) // If the actual password changed, the vault seal is stale. Probe it @@ -507,7 +503,8 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC cache = uekCache[0] } ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache) - ensureAdminsGroupMember(ctx, s, existing.ID) + auth.EnsureEveryoneGroup(ctx, s, existing.ID) + auth.AddToAdminsGroup(ctx, s, existing.ID) log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername) return } @@ -522,7 +519,6 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC Username: strings.ToLower(cfg.AdminUsername), Email: strings.ToLower(email), PasswordHash: string(hash), - Role: models.UserRoleAdmin, IsActive: true, AuthSource: "builtin", Handle: handle, @@ -532,13 +528,14 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC log.Printf("⚠ Failed to create admin user: %v", err) return } - ensureAdminsGroupMember(ctx, s, user.ID) + auth.EnsureEveryoneGroup(ctx, s, user.ID) + auth.AddToAdminsGroup(ctx, s, user.ID) log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername) } // SeedUsers creates or updates users from the SEED_USERS env var. -// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user". -// Upsert: existing users get their password and role refreshed on every restart. +// Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group. +// Upsert: existing users get their password refreshed on every restart. // Gated to non-production environments. func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) { if cfg.SeedUsers == "" { @@ -563,16 +560,13 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) parts := strings.SplitN(entry, ":", 3) if len(parts) < 2 { - log.Printf("⚠ Seed user skipped (bad format, want user:pass[:role]): %q", entry) + log.Printf("⚠ Seed user skipped (bad format, want user:pass[:admin]): %q", entry) continue } username := strings.ToLower(strings.TrimSpace(parts[0])) password := strings.TrimSpace(parts[1]) - role := models.UserRoleUser - if len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" { - role = models.UserRoleAdmin - } + isAdmin := len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" if username == "" || password == "" { log.Printf("⚠ Seed user skipped (empty username or password)") @@ -585,15 +579,13 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) continue } - // Upsert: update password+role if user exists, create if not + // Upsert: update password if user exists, create if not existing, _ := s.Users.GetByUsername(ctx, username) if existing != nil { s.Users.Update(ctx, existing.ID, map[string]interface{}{ "password_hash": string(hash), - "role": role, "is_active": true, }) - // Probe vault with current password — only destroys if seal is stale if existing.Handle == "" { handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username)) s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) @@ -603,10 +595,11 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) cache = uekCache[0] } ProbeAndRepairVault(ctx, s, existing.ID, password, cache) - if role == models.UserRoleAdmin { - ensureAdminsGroupMember(ctx, s, existing.ID) + auth.EnsureEveryoneGroup(ctx, s, existing.ID) + if isAdmin { + auth.AddToAdminsGroup(ctx, s, existing.ID) } - log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role) + log.Printf(" 🌱 Seed user '%s' updated (admin=%v)", username, isAdmin) continue } @@ -615,7 +608,6 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) Username: username, Email: username + "@switchboard.local", PasswordHash: string(hash), - Role: role, IsActive: true, AuthSource: "builtin", Handle: handle, @@ -625,20 +617,14 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) log.Printf("⚠ Seed user '%s' failed: %v", username, err) continue } - if role == models.UserRoleAdmin { - ensureAdminsGroupMember(ctx, s, user.ID) + auth.EnsureEveryoneGroup(ctx, s, user.ID) + if isAdmin { + auth.AddToAdminsGroup(ctx, s, user.ID) } - log.Printf(" 🌱 Seed user '%s' created (role=%s, active=true)", username, role) + log.Printf(" 🌱 Seed user '%s' created (admin=%v, active=true)", username, isAdmin) } } -// ensureAdminsGroupMember adds userID to the Admins system group (idempotent). -// Used by BootstrapAdmin and SeedUsers so that admin users receive -// surface.admin.access through the normal permission resolution path. -func ensureAdminsGroupMember(ctx context.Context, s store.Stores, userID string) { - auth.SyncAdminsGroupMembership(ctx, s, userID, models.UserRoleAdmin) -} - // IsRegistrationEnabled checks the platform policy. func IsRegistrationEnabled(s store.Stores) bool { val, _ := s.Policies.GetBool(context.Background(), "allow_registration") diff --git a/server/handlers/auth_test.go b/server/handlers/auth_test.go index 761499e..53de73c 100644 --- a/server/handlers/auth_test.go +++ b/server/handlers/auth_test.go @@ -40,7 +40,6 @@ func TestJWTGeneration(t *testing.T) { claims := Claims{ UserID: "550e8400-e29b-41d4-a716-446655440000", Email: "test@example.com", - Role: "user", RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), @@ -75,9 +74,6 @@ func TestJWTGeneration(t *testing.T) { if parsed.Email != claims.Email { t.Errorf("Email mismatch: got %s, want %s", parsed.Email, claims.Email) } - if parsed.Role != claims.Role { - t.Errorf("Role mismatch: got %s, want %s", parsed.Role, claims.Role) - } } func TestJWTExpired(t *testing.T) { @@ -86,7 +82,6 @@ func TestJWTExpired(t *testing.T) { claims := Claims{ UserID: "test-user", Email: "test@example.com", - Role: "user", RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)), @@ -109,7 +104,6 @@ func TestJWTWrongSecret(t *testing.T) { claims := Claims{ UserID: "test-user", Email: "test@example.com", - Role: "user", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), }, diff --git a/server/handlers/extension_test.go b/server/handlers/extension_test.go index 8ee89c8..c724df7 100644 --- a/server/handlers/extension_test.go +++ b/server/handlers/extension_test.go @@ -81,17 +81,19 @@ func setupExtensionHarness(t *testing.T) *extensionHarness { // Seed admin user adminID := seedInsertReturningID(t, - `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - "ext-admin", "ext-admin@test.com", "$2a$10$test", "admin", "ext-admin", "builtin", + `INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`, + "ext-admin", "ext-admin@test.com", "$2a$10$test", "ext-admin", "builtin", ) + database.SeedEveryoneGroupMember(t, adminID) database.SeedAdminsGroupMember(t, adminID) adminToken := makeToken(adminID, "ext-admin@test.com", "admin") // Seed regular user userID := seedInsertReturningID(t, - `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - "ext-user", "ext-user@test.com", "$2a$10$test", "user", "ext-user", "builtin", + `INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`, + "ext-user", "ext-user@test.com", "$2a$10$test", "ext-user", "builtin", ) + database.SeedEveryoneGroupMember(t, userID) userToken := makeToken(userID, "ext-user@test.com", "user") return &extensionHarness{ diff --git a/server/handlers/notification_test.go b/server/handlers/notification_test.go index fb3a358..55e6b94 100644 --- a/server/handlers/notification_test.go +++ b/server/handlers/notification_test.go @@ -74,7 +74,7 @@ func setupNotifHarness(t *testing.T) *notifHarness { // Seed admin adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID) + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), adminID) database.SeedAdminsGroupMember(t, adminID) adminToken := makeToken(adminID, "notif-admin@test.com", "admin") diff --git a/server/handlers/profile_bootstrap.go b/server/handlers/profile_bootstrap.go index 843a84c..228380f 100644 --- a/server/handlers/profile_bootstrap.go +++ b/server/handlers/profile_bootstrap.go @@ -33,7 +33,6 @@ func NewProfileBootstrapHandler(s store.Stores) *ProfileBootstrapHandler { // GET /api/v1/profile/bootstrap func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) { userID := getUserID(c) - role, _ := c.Get("role") ctx := c.Request.Context() // ── User profile ──────────────────────── @@ -48,27 +47,20 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) { "username": user.Username, "display_name": user.DisplayName, "email": user.Email, - "role": user.Role, } if user.AvatarURL != "" { userPayload["avatar"] = user.AvatarURL } // ── Permissions ───────────────────────── - var permList []string - if role == "admin" { - permList = make([]string, len(auth.AllPermissions)) - copy(permList, auth.AllPermissions) - } else { - perms, err := auth.ResolvePermissions(ctx, h.stores, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) - return - } - permList = make([]string, 0, len(perms)) - for p := range perms { - permList = append(permList, p) - } + perms, err := auth.ResolvePermissions(ctx, h.stores, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) + return + } + permList := make([]string, 0, len(perms)) + for p := range perms { + permList = append(permList, p) } sort.Strings(permList) @@ -77,7 +69,6 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) { if groupIDs == nil { groupIDs = []string{} } - groupIDs = append(groupIDs, auth.EveryoneGroupID) // ── Teams ─────────────────────────────── teams, _ := h.stores.Teams.ListForUser(ctx, userID) diff --git a/server/handlers/settings.go b/server/handlers/settings.go index 4baf7c3..9a82ef1 100644 --- a/server/handlers/settings.go +++ b/server/handlers/settings.go @@ -36,7 +36,6 @@ type profileResponse struct { Username string `json:"username"` Email string `json:"email"` DisplayName *string `json:"display_name"` - Role string `json:"role"` Avatar *string `json:"avatar,omitempty"` Settings map[string]interface{} `json:"settings"` CreatedAt time.Time `json:"created_at"` @@ -84,7 +83,6 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) { Username: user.Username, Email: user.Email, DisplayName: dn, - Role: user.Role, Avatar: avatar, Settings: settings, CreatedAt: user.CreatedAt, diff --git a/server/handlers/test_helpers_test.go b/server/handlers/test_helpers_test.go index 0ebad52..306a4d2 100644 --- a/server/handlers/test_helpers_test.go +++ b/server/handlers/test_helpers_test.go @@ -41,11 +41,10 @@ func (h *testHarness) request(method, path, token string, body interface{}) *htt return w } -func makeToken(userID, email, role string) string { +func makeToken(userID, email, _ string) string { claims := Claims{ UserID: userID, Email: email, - Role: role, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), diff --git a/server/main.go b/server/main.go index 2e935dc..220b1e7 100644 --- a/server/main.go +++ b/server/main.go @@ -287,7 +287,6 @@ func main() { HeaderFingerprint: cfg.MTLSHeaderFingerprint, AutoActivate: cfg.MTLSAutoActivate, DefaultTeam: cfg.MTLSDefaultTeam, - DefaultRole: cfg.MTLSDefaultRole, }) case auth.ModeOIDC: var err error @@ -299,7 +298,6 @@ func main() { RedirectURL: cfg.OIDCRedirectURL, AutoActivate: cfg.OIDCAutoActivate, DefaultTeam: cfg.OIDCDefaultTeam, - DefaultRole: cfg.OIDCDefaultRole, RolesClaim: cfg.OIDCRolesClaim, GroupsClaim: cfg.OIDCGroupsClaim, AdminRole: cfg.OIDCAdminRole, diff --git a/server/middleware/auth.go b/server/middleware/auth.go index 5200db2..a29b354 100644 --- a/server/middleware/auth.go +++ b/server/middleware/auth.go @@ -20,19 +20,17 @@ import ( type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` - Role string `json:"role"` jwt.RegisteredClaims } // ─── User Status Cache ────────────────────────────────────── -// Short-TTL cache so deactivation and role changes take effect -// within seconds, without a DB query on every single request. +// Short-TTL cache so deactivation takes effect within seconds, +// without a DB query on every single request. const userCacheTTL = 30 * time.Second type userCacheEntry struct { isActive bool - role string fetchedAt time.Time } @@ -64,9 +62,9 @@ func (c *UserStatusCache) get(userID string) (userCacheEntry, bool) { return e, true } -func (c *UserStatusCache) set(userID string, active bool, role string) { +func (c *UserStatusCache) set(userID string, active bool) { c.mu.Lock() - c.entries[userID] = userCacheEntry{isActive: active, role: role, fetchedAt: time.Now()} + c.entries[userID] = userCacheEntry{isActive: active, fetchedAt: time.Now()} c.mu.Unlock() } @@ -90,44 +88,43 @@ func (c *UserStatusCache) evictExpired() { // ─── Shared user verification ─────────────────────────────── -// verifyUser checks is_active and resolves the current DB role. -// Returns (role, nil) on success or ("", error-already-sent) on failure. -// Callers must abort the request on error. -func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) (string, bool) { +// verifyUser checks that the user exists and is active. +// Returns true on success or false (with error already sent) on failure. +func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) bool { userID := claims.UserID if userID == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"}) - return "", false + return false } return verifyUserByID(c, userID, users, cache) } -// verifyUserByID checks is_active and resolves the current DB role for a user ID. +// verifyUserByID checks is_active for a user ID. // Used by both JWT auth (via verifyUser) and ticket auth. -func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) (string, bool) { +func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) bool { // Cache hit if entry, ok := cache.get(userID); ok { if !entry.isActive { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) - return "", false + return false } - return entry.role, true + return true } // Cache miss → DB lookup user, err := users.GetByID(c.Request.Context(), userID) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) - return "", false + return false } - cache.set(userID, user.IsActive, user.Role) + cache.set(userID, user.IsActive) if !user.IsActive { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) - return "", false + return false } - return user.Role, true + return true } // parseAndValidateJWT parses a raw JWT string and returns claims if valid. @@ -188,15 +185,13 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin return } - // Verify user is active and resolve current role from DB - role, ok := verifyUser(c, claims, users, cache) - if !ok { - return // verifyUser already sent the error response + // Verify user is active + if !verifyUser(c, claims, users, cache) { + return } c.Set("user_id", claims.UserID) c.Set("email", claims.Email) - c.Set("role", role) // from DB, not JWT claims c.Next() } } @@ -304,14 +299,11 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t return } - // Ticket is valid — resolve role from DB (same as JWT path) - role, ok := verifyUserByID(c, userID, users, cache) - if !ok { + if !verifyUserByID(c, userID, users, cache) { return } c.Set("user_id", userID) - c.Set("role", role) c.Set("ws_auth", "ticket") c.Next() return @@ -329,14 +321,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t return } - role, ok := verifyUser(c, claims, users, cache) - if !ok { + if !verifyUser(c, claims, users, cache) { return } c.Set("user_id", claims.UserID) c.Set("email", claims.Email) - c.Set("role", role) c.Set("ws_auth", "token_legacy") c.Next() return @@ -367,14 +357,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t return } - role, ok := verifyUser(c, claims, users, cache) - if !ok { + if !verifyUser(c, claims, users, cache) { return } c.Set("user_id", claims.UserID) c.Set("email", claims.Email) - c.Set("role", role) c.Set("ws_auth", "bearer") c.Next() } diff --git a/server/middleware/page_auth.go b/server/middleware/page_auth.go index 1c578f0..5525703 100644 --- a/server/middleware/page_auth.go +++ b/server/middleware/page_auth.go @@ -61,27 +61,23 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus return } - // Resolve user role — redirect (not JSON) on any failure. - var role string + // Check user is active — redirect (not JSON) on failure. if entry, hit := cache.get(claims.UserID); hit { if !entry.isActive { redirectToLogin(c, loginPath) return } - role = entry.role } else { user, err := users.GetByID(c.Request.Context(), claims.UserID) if err != nil || !user.IsActive { redirectToLogin(c, loginPath) return } - cache.set(claims.UserID, user.IsActive, user.Role) - role = user.Role + cache.set(claims.UserID, user.IsActive) } c.Set("user_id", claims.UserID) c.Set("email", claims.Email) - c.Set("role", role) c.Next() } } diff --git a/server/models/models.go b/server/models/models.go index 988ade3..87bee9b 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -23,12 +23,9 @@ const ( ScopePersonal = "personal" ) -// ── Role Constants ────────────────────────── +// ── Team Role Constants ───────────────────── const ( - UserRoleUser = "user" - UserRoleAdmin = "admin" - TeamRoleAdmin = "admin" TeamRoleMember = "member" ) @@ -48,7 +45,6 @@ type User struct { PasswordHash string `json:"-" db:"password_hash"` DisplayName string `json:"display_name,omitempty" db:"display_name"` AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"` - Role string `json:"role" db:"role"` IsActive bool `json:"is_active" db:"is_active"` Settings JSONMap `json:"settings,omitempty" db:"settings"` LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` diff --git a/server/notifications/sources.go b/server/notifications/sources.go index 7f36985..deac952 100644 --- a/server/notifications/sources.go +++ b/server/notifications/sources.go @@ -6,6 +6,7 @@ import ( "fmt" "log" + "switchboard-core/auth" "switchboard-core/events" "switchboard-core/models" "switchboard-core/store" @@ -44,26 +45,23 @@ func RoleFallbackHandler(svc *Service, stores store.Stores) events.Handler { body = fmt.Sprintf("Primary model error: %s", p.Error) } - // Notify all admin users + // Notify all Admins group members ctx := context.Background() - admins, _, err := stores.Users.List(ctx, store.ListOptions{Limit: 500}) + admins, err := stores.Groups.ListMembers(ctx, auth.AdminsGroupID) if err != nil { - log.Printf("[notifications] failed to list users for role.fallback: %v", err) + log.Printf("[notifications] failed to list admins for role.fallback: %v", err) return } for _, u := range admins { - if u.Role != "admin" { - continue - } n := models.Notification{ - UserID: u.ID, + UserID: u.UserID, Type: models.NotifTypeRoleFallback, Title: title, Body: body, } if err := svc.Notify(ctx, &n); err != nil { - log.Printf("[notifications] failed to create role.fallback notification for %s: %v", u.ID, err) + log.Printf("[notifications] failed to create role.fallback notification for %s: %v", u.UserID, err) } } } diff --git a/server/store/interfaces.go b/server/store/interfaces.go index a34dbec..77e037e 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -93,9 +93,6 @@ type UserStore interface { // ── CS2 additions (v0.29.0) ── - // CountByRole returns the number of users with a given role. - CountByRole(ctx context.Context, role string) (int, error) - // CountAll returns the total number of users. CountAll(ctx context.Context) (int, error) diff --git a/server/store/postgres/user.go b/server/store/postgres/user.go index 3ad2bc5..14350f2 100644 --- a/server/store/postgres/user.go +++ b/server/store/postgres/user.go @@ -16,10 +16,10 @@ type UserStore struct{} func NewUserStore() *UserStore { return &UserStore{} } const userCols = `id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at, + is_active, settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` -const userListCols = `id, username, email, display_name, avatar_url, role, is_active, +const userListCols = `id, username, email, display_name, avatar_url, is_active, settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` func (s *UserStore) Create(ctx context.Context, u *models.User) error { @@ -27,10 +27,10 @@ func (s *UserStore) Create(ctx context.Context, u *models.User) error { u.AuthSource = "builtin" } return DB.QueryRowContext(ctx, ` - INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings, auth_source, external_id, handle) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO users (username, email, password_hash, display_name, is_active, settings, auth_source, external_id, handle) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, updated_at`, - u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, + u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive, ToJSON(u.Settings), u.AuthSource, u.ExternalID, u.Handle, ).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt) } @@ -100,7 +100,7 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models. var extID, hdl sql.NullString var sj []byte if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, - &u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, + &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, &u.AuthSource, &extID, &hdl); err != nil { return nil, 0, err } @@ -173,7 +173,7 @@ func scanOneUser(ctx context.Context, query string, args ...interface{}) (*model var sj []byte err := DB.QueryRowContext(ctx, query, args...).Scan( &u.ID, &u.Username, &u.Email, &ph, &dn, &av, - &u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, + &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, &u.AuthSource, &extID, &hdl, ) if err != nil { @@ -233,13 +233,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin // ── CS2 additions (v0.29.0) ───────────────────────────────────────────── -func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM users WHERE role = $1`, role).Scan(&count) - return count, err -} - func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { _, err := DB.ExecContext(ctx, ` UPDATE users SET settings = ( diff --git a/server/store/sqlite/user.go b/server/store/sqlite/user.go index 2f7b31a..3cb450d 100644 --- a/server/store/sqlite/user.go +++ b/server/store/sqlite/user.go @@ -16,10 +16,10 @@ type UserStore struct{} func NewUserStore() *UserStore { return &UserStore{} } const userCols = `id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at, + is_active, settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` -const userListCols = `id, username, email, display_name, avatar_url, role, is_active, +const userListCols = `id, username, email, display_name, avatar_url, is_active, settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` func (s *UserStore) Create(ctx context.Context, u *models.User) error { @@ -31,10 +31,10 @@ func (s *UserStore) Create(ctx context.Context, u *models.User) error { u.AuthSource = "builtin" } _, err := DB.ExecContext(ctx, ` - INSERT INTO users (id, username, email, password_hash, display_name, role, is_active, + INSERT INTO users (id, username, email, password_hash, display_name, is_active, settings, created_at, updated_at, auth_source, external_id, handle) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive, ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt), u.AuthSource, u.ExternalID, u.Handle, ) @@ -106,7 +106,7 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models. var extID, hdl sql.NullString var sj []byte if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, - &u.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), + &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), &u.AuthSource, &extID, &hdl); err != nil { return nil, 0, err } @@ -180,7 +180,7 @@ func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface var sj []byte err := DB.QueryRowContext(ctx, query, args...).Scan( &u.ID, &u.Username, &u.Email, &ph, &dn, &av, - &u.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), + &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), &u.AuthSource, &extID, &hdl, ) if err != nil { @@ -240,13 +240,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin // ── CS2 additions (v0.29.0) ───────────────────────────────────────────── -func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM users WHERE role = ?`, role).Scan(&count) - return count, err -} - func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { _, err := DB.ExecContext(ctx, ` UPDATE users SET settings = json_patch( diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 73a50e7..8a0ae05 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -166,7 +166,7 @@ export function createDomains(restClient) { create: (data) => rc.post('/api/v1/admin/users', data), del: (id) => rc.del(`/api/v1/admin/users/${id}`), resetPassword: (id, pw) => rc.post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }), - updateRole: (id, role) => rc.put(`/api/v1/admin/users/${id}/role`, { role }), + updateRole: (id, isAdmin) => rc.put(`/api/v1/admin/users/${id}/role`, { is_admin: isAdmin }), toggleActive: (id, active, opts) => rc.put(`/api/v1/admin/users/${id}/active`, { is_active: active, ...opts }), permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`), resetVault: (id) => rc.post(`/api/v1/admin/users/${id}/vault/reset`, {}), diff --git a/src/js/sw/surfaces/admin/users.js b/src/js/sw/surfaces/admin/users.js index 3b5be51..c0e488c 100644 --- a/src/js/sw/surfaces/admin/users.js +++ b/src/js/sw/surfaces/admin/users.js @@ -33,7 +33,7 @@ export default function UsersSection() { username: form.username.value.trim(), email: form.email.value.trim(), password: form.password.value, - role: form.role.value, + is_admin: form.is_admin.checked, }; if (!data.username || !data.password) return sw.toast('Username and password required', 'error'); try { @@ -52,10 +52,10 @@ export default function UsersSection() { } catch (e) { sw.toast(e.message, 'error'); } } - async function changeRole(id, role) { + async function toggleAdmin(id, isAdmin) { try { - await sw.api.admin.users.updateRole(id, role); - sw.toast(`Role updated to ${role}`, 'success'); + await sw.api.admin.users.updateRole(id, isAdmin); + sw.toast(isAdmin ? 'Admin granted' : 'Admin revoked', 'success'); load(); } catch (e) { sw.toast(e.message, 'error'); } } @@ -112,8 +112,8 @@ export default function UsersSection() {
-
- +
+
@@ -131,17 +131,11 @@ export default function UsersSection() { ${u.username} ${u.email || ''} ${' '}${statusBadge(u)} - ${' '}${u.role}
${u.status === 'pending' && html` `} - ${u.is_active ? html`` : html`` -- 2.49.1