From b10f5bee0533a8065ad390fbd84604c799ec4658 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 17:23:12 +0000 Subject: [PATCH] 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``