admin → RBAC group migration: grant-based access replaces role check
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 19s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Successful in 2m14s
CI/CD / test-sqlite (pull_request) Successful in 2m56s
CI/CD / build-and-deploy (pull_request) Successful in 1m21s

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:14:46 +00:00
parent b9180d4184
commit 316c9cbfa7
15 changed files with 208 additions and 50 deletions

View File

@@ -2,7 +2,39 @@
All notable changes to Switchboard Core are documented here. 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. Forked from chat-switchboard v0.38.5. Gutted to a pure extension platform.

View File

@@ -36,11 +36,17 @@ platform that extensions build on.
The contract that extensions build against. Three trigger primitives, The contract that extensions build against. Three trigger primitives,
SDK stabilization, and the first rebuilt extension (tasks). 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 - **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 - **Task extension**: first proof-of-concept — full task system rebuilt as a
Starlark extension using triggers + ext_data + notifications Starlark extension using triggers + ext_data + notifications
- **Settings override model**: RBAC controls scope auth (admin → global, - **Settings override model**: RBAC controls scope auth (admin → global,

View File

@@ -171,6 +171,9 @@ func (p *OIDCProvider) Authenticate(c *gin.Context, stores store.Stores) (*Resul
user.Role = role user.Role = role
} }
// Sync Admins group membership with resolved role
SyncAdminsGroupMembership(ctx, stores, user.ID, role)
// Sync groups // Sync groups
p.syncGroups(ctx, user.ID, claims, stores) 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) 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 // Auto-add to default team
if p.cfg.DefaultTeam != "" { if p.cfg.DefaultTeam != "" {
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil { if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {

View File

@@ -8,23 +8,30 @@ import (
) )
// EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in // 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. // explicit membership row.
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001" 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. // Permission constants — domain.action convention.
const ( const (
PermExtensionUse = "extension.use" // use installed extensions PermSurfaceAdminAccess = "surface.admin.access" // full admin panel access (replaces role check)
PermExtensionInstall = "extension.install" // install/manage extension packages PermExtensionUse = "extension.use" // use installed extensions
PermWorkflowCreate = "workflow.create" // create workflow definitions PermExtensionInstall = "extension.install" // install/manage extension packages
PermWorkflowSubmit = "workflow.submit" // submit to public workflows PermWorkflowCreate = "workflow.create" // create workflow definitions
PermAdminView = "admin.view" // read-only admin panel access PermWorkflowSubmit = "workflow.submit" // submit to public workflows
PermTokenUnlimited = "token.unlimited" // bypass token budgets PermAdminView = "admin.view" // read-only admin panel access
PermTokenUnlimited = "token.unlimited" // bypass token budgets
) )
// AllPermissions is the complete set of valid permission strings. // AllPermissions is the complete set of valid permission strings.
// Used for validation in handlers and rendering checkboxes in admin UI. // Used for validation in handlers and rendering checkboxes in admin UI.
var AllPermissions = []string{ var AllPermissions = []string{
PermSurfaceAdminAccess,
PermExtensionUse, PermExtensionUse,
PermExtensionInstall, PermExtensionInstall,
PermWorkflowCreate, PermWorkflowCreate,
@@ -37,7 +44,7 @@ var AllPermissions = []string{
// ResolvePermissions returns the effective permission set for a user. // ResolvePermissions returns the effective permission set for a user.
// Always includes the Everyone group regardless of membership. // 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) { func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
perms := make(map[string]bool) perms := make(map[string]bool)
@@ -62,6 +69,17 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string)
return perms, nil 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 ──────────────────────────── // ── Token Budget ────────────────────────────
// TokenBudget holds the resolved ceiling for a user and the time window it covers. // TokenBudget holds the resolved ceiling for a user and the time window it covers.

View File

@@ -83,3 +83,16 @@ VALUES (
'["extension.use","workflow.submit"]'::jsonb '["extension.use","workflow.submit"]'::jsonb
) )
ON CONFLICT (id) DO NOTHING; 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;

View File

@@ -74,3 +74,15 @@ VALUES (
'global', NULL, 'system', 'global', NULL, 'system',
'["extension.use","workflow.submit"]' '["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"]'
);

View File

@@ -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. // Re-seed default config rows.
if IsSQLite() { if IsSQLite() {
DB.Exec(` DB.Exec(`
@@ -377,6 +402,27 @@ func SeedTestUser(t *testing.T, username, email string) string {
return id 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. // SeedTestChannel creates a test channel owned by userID and returns the channel ID.
func SeedTestChannel(t *testing.T, userID, title string) string { func SeedTestChannel(t *testing.T, userID, title string) string {
t.Helper() t.Helper()

View File

@@ -100,12 +100,16 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
return 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) h.auditLog(c, "user.create", "user", user.ID, nil)
c.JSON(http.StatusCreated, user) c.JSON(http.StatusCreated, user)
} }
func (h *AdminHandler) UpdateUserRole(c *gin.Context) { func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
ctx := c.Request.Context()
var req struct { var req struct {
Role string `json:"role" binding:"required"` Role string `json:"role" binding:"required"`
} }
@@ -114,27 +118,30 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
return return
} }
// Guard: prevent demoting the last admin user, err := h.stores.Users.GetByID(ctx, id)
if req.Role != models.UserRoleAdmin { if err != nil || user == nil {
user, err := h.stores.Users.GetByID(c.Request.Context(), id) c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
if err != nil || user == nil { return
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) }
// 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 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
return 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{"role": req.Role})
if h.onUserChanged != nil { if h.onUserChanged != nil {
h.onUserChanged(id) h.onUserChanged(id)
@@ -254,8 +261,8 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
return return
} }
if user.Role == models.UserRoleAdmin { if user.Role == models.UserRoleAdmin {
adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin) members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID)
if adminCount <= 1 { if len(members) <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"}) c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"})
return return
} }

View File

@@ -507,6 +507,7 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC
cache = uekCache[0] cache = uekCache[0]
} }
ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache) ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache)
ensureAdminsGroupMember(ctx, s, existing.ID)
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername) log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return 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) log.Printf("⚠ Failed to create admin user: %v", err)
return return
} }
ensureAdminsGroupMember(ctx, s, user.ID)
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername) 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] cache = uekCache[0]
} }
ProbeAndRepairVault(ctx, s, existing.ID, password, cache) 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) log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
continue 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) log.Printf("⚠ Seed user '%s' failed: %v", username, err)
continue continue
} }
if role == models.UserRoleAdmin {
ensureAdminsGroupMember(ctx, s, user.ID)
}
log.Printf(" 🌱 Seed user '%s' created (role=%s, active=true)", username, role) 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. // IsRegistrationEnabled checks the platform policy.
func IsRegistrationEnabled(s store.Stores) bool { func IsRegistrationEnabled(s store.Stores) bool {
val, _ := s.Policies.GetBool(context.Background(), "allow_registration") val, _ := s.Policies.GetBool(context.Background(), "allow_registration")

View File

@@ -73,7 +73,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
// Admin extension endpoints // Admin extension endpoints
admin := api.Group("/admin") 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.GET("/extensions", extH.AdminListExtensions)
admin.POST("/extensions", extH.AdminInstallExtension) admin.POST("/extensions", extH.AdminInstallExtension)
admin.PUT("/extensions/:id", extH.AdminUpdateExtension) 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`, `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", "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") adminToken := makeToken(adminID, "ext-admin@test.com", "admin")
// Seed regular user // Seed regular user

View File

@@ -65,7 +65,7 @@ func setupNotifHarness(t *testing.T) *notifHarness {
// Admin broadcast route (v0.28.6) // Admin broadcast route (v0.28.6)
admin := api.Group("/admin") admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache)) admin.Use(middleware.Auth(cfg, stores.Users, userCache))
admin.Use(middleware.RequireAdmin()) admin.Use(middleware.RequireAdmin(stores))
admin.POST("/notifications/broadcast", notifH.Broadcast) admin.POST("/notifications/broadcast", notifH.Broadcast)
// Set up notification service singleton for Broadcast handler // Set up notification service singleton for Broadcast handler
@@ -75,6 +75,7 @@ func setupNotifHarness(t *testing.T) *notifHarness {
// Seed admin // Seed admin
adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com") 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 role = 'admin', is_active = true WHERE id = $1"), adminID)
database.SeedAdminsGroupMember(t, adminID)
adminToken := makeToken(adminID, "notif-admin@test.com", "admin") adminToken := makeToken(adminID, "notif-admin@test.com", "admin")
// Seed regular user // Seed regular user

View File

@@ -513,7 +513,7 @@ func main() {
// ── Admin routes ──────────────────────── // ── Admin routes ────────────────────────
admin := api.Group("/admin") admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache)) admin.Use(middleware.Auth(cfg, stores.Users, userCache))
admin.Use(middleware.RequireAdmin()) admin.Use(middleware.RequireAdmin(stores))
admin.Use(middleware.ValidatePathParams()) admin.Use(middleware.ValidatePathParams())
{ {
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore) adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore)
@@ -693,7 +693,7 @@ func main() {
Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache), Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache),
Admin: []gin.HandlerFunc{ Admin: []gin.HandlerFunc{
middleware.AuthOrRedirect(cfg, stores.Users, userCache), 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) Session: middleware.AuthOrRedirect(cfg, stores.Users, userCache), // TODO: session middleware for workflow visitors (v0.2.0)
}) })

View File

@@ -4,14 +4,19 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"switchboard-core/auth"
"switchboard-core/store"
) )
// RequireAdmin returns middleware that restricts access to admin users. // RequireAdmin returns middleware that restricts access to users with the
// Must be used after Auth() middleware which sets "role" in context. // surface.admin.access permission (i.e. members of the Admins group).
func RequireAdmin() gin.HandlerFunc { // Must be used after Auth() middleware which sets "user_id" in context.
func RequireAdmin(stores store.Stores) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get("role") userID := c.GetString("user_id")
if !exists || role != "admin" { perms, err := resolveAndCachePerms(c, stores, userID)
if err != nil || !perms[auth.PermSurfaceAdminAccess] {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "admin access required", "error": "admin access required",
}) })

View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"switchboard-core/auth"
"switchboard-core/config" "switchboard-core/config"
"switchboard-core/database" "switchboard-core/database"
"switchboard-core/store" "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. // Use after AuthOrRedirect for admin-only page routes.
func RequireAdminPage() gin.HandlerFunc { func RequireAdminPage(stores store.Stores) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, _ := c.Get("role") userID := c.GetString("user_id")
if role != "admin" { perms, err := resolveAndCachePerms(c, stores, userID)
if err != nil || !perms[auth.PermSurfaceAdminAccess] {
c.String(http.StatusForbidden, "Admin access required") c.String(http.StatusForbidden, "Admin access required")
c.Abort() c.Abort()
return return

View File

@@ -12,17 +12,11 @@ import (
const permCacheKey = "resolved_permissions" const permCacheKey = "resolved_permissions"
// RequirePermission returns middleware that enforces a named permission. // RequirePermission returns middleware that enforces a named permission.
// Admin users bypass the check entirely. Permission resolution is cached in // Permission resolution is cached in the request context — computed at most
// the request context — computed at most once per request regardless of how // once per request regardless of how many RequirePermission middlewares are
// many RequirePermission middlewares are chained. // chained. Admins receive permissions through the Admins group.
func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc { func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, _ := c.Get("role")
if role == "admin" {
c.Next()
return
}
userID := c.GetString("user_id") userID := c.GetString("user_id")
perms, err := resolveAndCachePerms(c, stores, userID) perms, err := resolveAndCachePerms(c, stores, userID)
if err != nil || !perms[perm] { if err != nil || !perms[perm] {