From 316c9cbfa714964c5a0fe784b3b4dec99c533f4e Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 16:14:46 +0000 Subject: [PATCH] =?UTF-8?q?admin=20=E2=86=92=20RBAC=20group=20migration:?= =?UTF-8?q?=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] {