drop users.role column: full RBAC through group membership
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m22s
CI/CD / test-sqlite (pull_request) Successful in 2m35s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 17:23:12 +00:00
parent e8e45184f7
commit b10f5bee05
28 changed files with 206 additions and 285 deletions

View File

@@ -6,40 +6,51 @@ All notable changes to Switchboard Core are documented here.
### Added ### Added
- **Admin → RBAC group migration**: `surface.admin.access` permission replaces - **Full RBAC**: all authorization flows through group membership and permission
hardcoded `role == "admin"` checks. Seeded "Admins" system group carries all grants. No magic roles, no implicit group membership, no special-casing.
platform permissions. Admin middleware now resolves grants through the standard - `surface.admin.access` permission — any group can grant admin panel access
permission system — no special-casing. Any group can grant `surface.admin.access`. - Admins system group seeded with all platform permissions
- `AdminsGroupID` constant (`00000000-0000-0000-0000-000000000002`) - Everyone system group — all users explicitly added on creation
- `SyncAdminsGroupMembership()` — shared helper used by bootstrap, seed, OIDC, - `EnsureEveryoneGroup()`, `AddToAdminsGroup()`, `RemoveFromAdminsGroup()` helpers
and admin handlers to keep group membership in sync with role changes - `SeedAdminsGroupMember()`, `SeedEveryoneGroupMember()` test helpers
- `SeedAdminsGroupMember()` test helper
- System groups re-seeded after `TruncateAll` in test helper - System groups re-seeded after `TruncateAll` in test helper
- OIDC `isIdPAdmin()` — maps IdP role claims to Admins group membership
### Changed ### Changed
- `RequireAdmin()` / `RequireAdminPage()` now accept `store.Stores` and check - `RequireAdmin()` / `RequireAdminPage()` check `surface.admin.access` grant
`surface.admin.access` grant instead of `role == "admin"` - `RequirePermission()` no longer bypasses for admin role
- `RequirePermission()` no longer bypasses checks for admin role — admins get - `ResolvePermissions()` unions explicit group memberships only (no implicit Everyone)
permissions through group membership like everyone else - All user creation paths (builtin, OIDC, mTLS, admin, bootstrap, seed) add to
- Bootstrap/seed/OIDC login all add admin users to Admins group Everyone group. Admin users added to Admins group.
- Admin create/update/delete handlers sync Admins group membership on role change - JWT claims no longer include `role` field
- Demotion/deletion safeguards count Admins group members instead of `CountByRole` - 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`) - Kernel permissions: 6 → 7 (added `surface.admin.access`)
- Admin users UI: role dropdown removed, admin managed through groups
### Removed ### Removed
- **Token budgets** from groups: `token_budget_daily`, `token_budget_monthly` - **`users.role` column** — dropped from schema, model, JWT, all handlers
columns, `ResolveTokenBudget()`, and all store/handler/UI code. Provider-era - `UserRoleAdmin`, `UserRoleUser` constants
cruft — token budgets belong in a future provider extension, not the kernel. - `CountByRole()` store method
- **Allowed models** from groups: `allowed_models` column, - `DefaultRole` config for OIDC and mTLS providers
`ResolveModelAllowlist()`, `toggleModel` UI. Same rationale. - `SyncAdminsGroupMembership()` (replaced by `AddToAdminsGroup`/`RemoveFromAdminsGroup`)
- `GroupPatch` fields: `ClearBudgetDaily`, `ClearBudgetMonthly`, - OIDC `resolveRole()` (replaced by `isIdPAdmin()`)
`ClearAllowedModels` — no longer needed - **Token budgets** from groups: columns, `ResolveTokenBudget()`, all store/handler/UI
- Admin groups UI: Token Budgets section, Allowed Models section, models API call - **Allowed models** from groups: column, `ResolveModelAllowlist()`, UI
- Admin groups UI: Token Budgets section, Allowed Models section
### Migration notes ### Migration notes
- 001_core.sql (both dialects): removed `role` column from users table
- 002_teams.sql (both dialects): added Admins group seed, removed - 002_teams.sql (both dialects): added Admins group seed, removed
`token_budget_daily`, `token_budget_monthly`, `allowed_models` columns `token_budget_daily`, `token_budget_monthly`, `allowed_models` columns
- No new migration files — edited in place per pre-MVP policy - No new migration files — edited in place per pre-MVP policy

View File

@@ -82,7 +82,6 @@ func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result
Username: strings.ToLower(req.Username), Username: strings.ToLower(req.Username),
Email: strings.ToLower(req.Email), Email: strings.ToLower(req.Email),
PasswordHash: string(hash), PasswordHash: string(hash),
Role: models.UserRoleUser,
IsActive: defaultActive, IsActive: defaultActive,
AuthSource: string(ModeBuiltin), AuthSource: string(ModeBuiltin),
Handle: handle, Handle: handle,
@@ -92,6 +91,8 @@ func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result
return nil, err return nil, err
} }
EnsureEveryoneGroup(ctx, stores, user.ID)
return &Result{User: user, IsNewUser: true, VaultHint: req.Password}, nil return &Result{User: user, IsNewUser: true, VaultHint: req.Password}, nil
} }

View File

@@ -19,7 +19,6 @@ type MTLSConfig struct {
HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint") HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint")
AutoActivate bool // auto-activate new users (default true) AutoActivate bool // auto-activate new users (default true)
DefaultTeam string // team ID for auto-provisioned users (optional) DefaultTeam string // team ID for auto-provisioned users (optional)
DefaultRole string // "user" (default) or "admin"
} }
// MTLSProvider authenticates via client certificate headers injected // MTLSProvider authenticates via client certificate headers injected
@@ -48,9 +47,6 @@ func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider {
if cfg.HeaderFingerprint == "" { if cfg.HeaderFingerprint == "" {
cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint" cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint"
} }
if cfg.DefaultRole == "" {
cfg.DefaultRole = models.UserRoleUser
}
return &MTLSProvider{cfg: cfg} return &MTLSProvider{cfg: cfg}
} }
@@ -134,7 +130,6 @@ func (p *MTLSProvider) autoProvision(
Username: handle, Username: handle,
Email: email, Email: email,
DisplayName: cn, DisplayName: cn,
Role: p.cfg.DefaultRole,
IsActive: true, IsActive: true,
AuthSource: string(ModeMTLS), AuthSource: string(ModeMTLS),
ExternalID: &fingerprint, ExternalID: &fingerprint,
@@ -146,6 +141,7 @@ func (p *MTLSProvider) autoProvision(
} }
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn) 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 // Auto-add to default team if configured
if p.cfg.DefaultTeam != "" { if p.cfg.DefaultTeam != "" {

View File

@@ -96,24 +96,17 @@ func TestMTLSConfig_Defaults(t *testing.T) {
if p.cfg.HeaderFingerprint != "X-SSL-Client-Fingerprint" { if p.cfg.HeaderFingerprint != "X-SSL-Client-Fingerprint" {
t.Errorf("HeaderFingerprint = %q, want X-SSL-Client-Fingerprint", p.cfg.HeaderFingerprint) 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) { func TestMTLSConfig_Custom(t *testing.T) {
p := NewMTLSProvider(MTLSConfig{ p := NewMTLSProvider(MTLSConfig{
HeaderDN: "X-Client-Cert-DN", HeaderDN: "X-Client-Cert-DN",
HeaderVerify: "X-Client-Cert-Verify", HeaderVerify: "X-Client-Cert-Verify",
DefaultRole: "admin",
}) })
if p.cfg.HeaderDN != "X-Client-Cert-DN" { if p.cfg.HeaderDN != "X-Client-Cert-DN" {
t.Errorf("HeaderDN = %q, want X-Client-Cert-DN", p.cfg.HeaderDN) 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) { func TestMTLSProvider_Mode(t *testing.T) {

View File

@@ -31,7 +31,6 @@ type OIDCConfig struct {
RedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback" RedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback"
AutoActivate bool // auto-activate new users (default true) AutoActivate bool // auto-activate new users (default true)
DefaultTeam string // team ID for auto-provisioned users (optional) 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") RolesClaim string // JWT claim for role mapping (default "realm_access.roles")
GroupsClaim string // JWT claim for group sync (default "groups") GroupsClaim string // JWT claim for group sync (default "groups")
AdminRole string // IdP role value that maps to admin (default "admin") AdminRole string // IdP role value that maps to admin (default "admin")
@@ -92,9 +91,6 @@ func NewOIDCProvider(cfg OIDCConfig) (*OIDCProvider, error) {
if cfg.ClientID == "" { if cfg.ClientID == "" {
return nil, fmt.Errorf("OIDC_CLIENT_ID is required") return nil, fmt.Errorf("OIDC_CLIENT_ID is required")
} }
if cfg.DefaultRole == "" {
cfg.DefaultRole = models.UserRoleUser
}
if cfg.RolesClaim == "" { if cfg.RolesClaim == "" {
cfg.RolesClaim = "realm_access.roles" cfg.RolesClaim = "realm_access.roles"
} }
@@ -164,16 +160,11 @@ func (p *OIDCProvider) Authenticate(c *gin.Context, stores store.Stores) (*Resul
return nil, ErrInactive return nil, ErrInactive
} }
// Re-evaluate role on every login // Sync Admins group based on IdP role claim
role := p.resolveRole(claims) if p.isIdPAdmin(claims) {
if role != user.Role { AddToAdminsGroup(ctx, stores, user.ID)
stores.Users.Update(ctx, user.ID, map[string]interface{}{"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)
@@ -313,14 +304,14 @@ func (p *OIDCProvider) validateToken(tokenString string) (*oidcClaims, error) {
return claims, nil return claims, nil
} }
func (p *OIDCProvider) resolveRole(claims *oidcClaims) string { // isIdPAdmin checks whether the OIDC claims include the configured admin role.
// Check realm_access.roles for admin role func (p *OIDCProvider) isIdPAdmin(claims *oidcClaims) bool {
for _, role := range claims.RealmAccess.Roles { for _, role := range claims.RealmAccess.Roles {
if role == p.cfg.AdminRole { 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 // syncGroups compares IdP groups claim with internal group memberships
@@ -457,14 +448,12 @@ func (p *OIDCProvider) autoProvision(
displayName = username displayName = username
} }
role := p.resolveRole(claims)
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username)) handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username))
user := &models.User{ user := &models.User{
Username: strings.ToLower(username), Username: strings.ToLower(username),
Email: strings.ToLower(email), Email: strings.ToLower(email),
DisplayName: displayName, DisplayName: displayName,
Role: role,
IsActive: true, IsActive: true,
AuthSource: string(ModeOIDC), AuthSource: string(ModeOIDC),
ExternalID: &sub, 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) log.Printf("[auth/oidc] auto-provisioned user %s (sub=%s, email=%s)", user.ID, sub, email)
// Sync Admins group membership with resolved role EnsureEveryoneGroup(ctx, stores, user.ID)
SyncAdminsGroupMembership(ctx, stores, user.ID, role) if p.isIdPAdmin(claims) {
AddToAdminsGroup(ctx, stores, user.ID)
}
// Auto-add to default team // Auto-add to default team
if p.cfg.DefaultTeam != "" { if p.cfg.DefaultTeam != "" {

View File

@@ -42,19 +42,10 @@ var AllPermissions = []string{
// ── Resolution ────────────────────────────── // ── Resolution ──────────────────────────────
// 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. // Unions permissions from all groups the user is a member of (including Everyone).
// 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)
// 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) groups, err := stores.Groups.ListForUser(ctx, userID)
if err != nil { if err != nil {
return perms, err return perms, err
@@ -68,14 +59,19 @@ 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 // EnsureEveryoneGroup adds a user to the Everyone group (idempotent).
// based on their role. Called by auth providers and admin handlers when // Called on every user creation path so that Everyone membership is explicit.
// a user's role changes so that permissions stay in sync. func EnsureEveryoneGroup(ctx context.Context, stores store.Stores, userID string) {
func SyncAdminsGroupMembership(ctx context.Context, stores store.Stores, userID, role string) { _ = stores.Groups.AddMember(ctx, EveryoneGroupID, userID, userID)
if role == "admin" { }
_ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID)
} else { // AddToAdminsGroup adds a user to the Admins group (idempotent).
_ = stores.Groups.RemoveMember(ctx, AdminsGroupID, userID) 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)
} }

View File

@@ -62,7 +62,6 @@ type Config struct {
MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint" MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint"
MTLSAutoActivate bool // auto-activate new users (default true) MTLSAutoActivate bool // auto-activate new users (default true)
MTLSDefaultTeam string // team ID for auto-provisioned users (optional) MTLSDefaultTeam string // team ID for auto-provisioned users (optional)
MTLSDefaultRole string // "user" (default) or "admin"
// OIDC (v0.24.1) // OIDC (v0.24.1)
OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard" 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" OIDCRedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback"
OIDCAutoActivate bool // auto-activate new users (default true) OIDCAutoActivate bool // auto-activate new users (default true)
OIDCDefaultTeam string // team ID for auto-provisioned users (optional) 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") OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles")
OIDCGroupsClaim string // JWT claim for group sync (default "groups") OIDCGroupsClaim string // JWT claim for group sync (default "groups")
OIDCAdminRole string // IdP role value that maps to admin (default "admin") 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"), MTLSHeaderFingerprint: getEnv("MTLS_HEADER_FINGERPRINT", "X-SSL-Client-Fingerprint"),
MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true), MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true),
MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""), MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""),
MTLSDefaultRole: getEnv("MTLS_DEFAULT_ROLE", "user"),
// OIDC // OIDC
OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""), OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""),
@@ -128,7 +125,6 @@ func Load() *Config {
OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""), OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""),
OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true), OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true),
OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""), OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""),
OIDCDefaultRole: getEnv("OIDC_DEFAULT_ROLE", "user"),
OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"), OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"),
OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"), OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"),
OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"), OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"),

View File

@@ -23,8 +23,6 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT, password_hash TEXT,
display_name VARCHAR(100), display_name VARCHAR(100),
avatar_url TEXT, avatar_url TEXT,
role VARCHAR(20) DEFAULT 'user'
CHECK (role IN ('user', 'admin')),
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb, settings JSONB DEFAULT '{}'::jsonb,
auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin' auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin'

View File

@@ -12,7 +12,6 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT, password_hash TEXT,
display_name TEXT, display_name TEXT,
avatar_url TEXT, avatar_url TEXT,
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin')),
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}', settings TEXT DEFAULT '{}',
auth_source TEXT NOT NULL DEFAULT 'builtin', auth_source TEXT NOT NULL DEFAULT 'builtin',

View File

@@ -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 { func SeedTestUser(t *testing.T, username, email string) string {
t.Helper() t.Helper()
handle := strings.ToLower(strings.ReplaceAll(username, " ", "-")) handle := strings.ToLower(strings.ReplaceAll(username, " ", "-"))
if IsSQLite() { if IsSQLite() {
id := uuid.New().String() id := uuid.New().String()
_, err := DB.Exec(` _, err := DB.Exec(`
INSERT INTO users (id, username, email, password_hash, role, auth_source, handle) INSERT INTO users (id, username, email, password_hash, auth_source, handle)
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', ?) VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', ?)
`, id, username, email, handle) `, id, username, email, handle)
if err != nil { if err != nil {
t.Fatalf("SeedTestUser: %v", err) t.Fatalf("SeedTestUser: %v", err)
} }
SeedEveryoneGroupMember(t, id)
return id return id
} }
var id string var id string
err := DB.QueryRow(` err := DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role, auth_source, handle) INSERT INTO users (username, email, password_hash, auth_source, handle)
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', $3) VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', $3)
RETURNING id RETURNING id
`, username, email, handle).Scan(&id) `, username, email, handle).Scan(&id)
if err != nil { if err != nil {
t.Fatalf("SeedTestUser: %v", err) t.Fatalf("SeedTestUser: %v", err)
} }
SeedEveryoneGroupMember(t, id)
return 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. // 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 // Call after creating a user with role='admin' so they receive surface.admin.access
// through the normal permission resolution path. // through the normal permission resolution path.

View File

@@ -69,29 +69,25 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"` Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"` Password string `json:"password" binding:"required,min=8"`
Role string `json:"role"` IsAdmin bool `json:"is_admin"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
ctx := c.Request.Context()
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
role := models.UserRoleUser
if req.Role == models.UserRoleAdmin {
role = models.UserRoleAdmin
}
user := &models.User{ user := &models.User{
Username: strings.ToLower(req.Username), Username: strings.ToLower(req.Username),
Email: strings.ToLower(req.Email), Email: strings.ToLower(req.Email),
PasswordHash: string(hash), PasswordHash: string(hash),
Role: role,
IsActive: true, 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) { if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "username or email already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "username or email already exists"})
return return
@@ -100,8 +96,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
return return
} }
// Sync Admins group membership with assigned role. auth.EnsureEveryoneGroup(ctx, h.stores, user.ID)
auth.SyncAdminsGroupMembership(c.Request.Context(), h.stores, user.ID, role) if req.IsAdmin {
auth.AddToAdminsGroup(ctx, h.stores, user.ID)
}
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)
@@ -111,42 +109,42 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
ctx := c.Request.Context() ctx := c.Request.Context()
var req struct { var req struct {
Role string `json:"role" binding:"required"` IsAdmin bool `json:"is_admin"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
user, err := h.stores.Users.GetByID(ctx, id) if _, err := h.stores.Users.GetByID(ctx, id); err != nil {
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return return
} }
// Guard: prevent demoting the last admin. if !req.IsAdmin {
// Count Admins group members — the source of truth for admin access. // Guard: prevent removing the last admin from Admins group.
if req.Role != models.UserRoleAdmin && user.Role == models.UserRoleAdmin {
members, _ := h.stores.Groups.ListMembers(ctx, auth.AdminsGroupID) members, _ := h.stores.Groups.ListMembers(ctx, auth.AdminsGroupID)
if len(members) <= 1 { isMember := false
c.JSON(http.StatusConflict, gin.H{"error": "cannot demote the last admin"}) 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 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 { h.auditLog(c, "user.role_change", "user", id, gin.H{"is_admin": req.IsAdmin})
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 { if h.onUserChanged != nil {
h.onUserChanged(id) 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) { func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
@@ -255,18 +253,22 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
// Guard: prevent deleting the last admin // Guard: prevent deleting the last admin
user, err := h.stores.Users.GetByID(c.Request.Context(), id) if _, err := h.stores.Users.GetByID(c.Request.Context(), id); err != nil {
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return return
} }
if user.Role == models.UserRoleAdmin {
members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID) members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID)
if len(members) <= 1 { 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"}) c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"})
return return
} }
}
// Destroy vault and personal BYOK keys before deleting the user row // Destroy vault and personal BYOK keys before deleting the user row
h.destroyVault(c, id) h.destroyVault(c, id)

View File

@@ -32,7 +32,6 @@ const bcryptCost = 12
type Claims struct { type Claims struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -295,7 +294,6 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
accessClaims := Claims{ accessClaims := Claims{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -327,7 +325,6 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
"username": user.Username, "username": user.Username,
"email": user.Email, "email": user.Email,
"display_name": user.DisplayName, "display_name": user.DisplayName,
"role": user.Role,
"handle": user.Handle, "handle": user.Handle,
"auth_source": user.AuthSource, "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) existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername)
if existing != nil { if existing != nil {
// Update password and ensure admin role // Update password
s.Users.Update(ctx, existing.ID, map[string]interface{}{ s.Users.Update(ctx, existing.ID, map[string]interface{}{
"password_hash": string(hash), "password_hash": string(hash),
"role": models.UserRoleAdmin,
"is_active": true, "is_active": true,
}) })
// If the actual password changed, the vault seal is stale. Probe it // 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] cache = uekCache[0]
} }
ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache) 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) log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return return
} }
@@ -522,7 +519,6 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC
Username: strings.ToLower(cfg.AdminUsername), Username: strings.ToLower(cfg.AdminUsername),
Email: strings.ToLower(email), Email: strings.ToLower(email),
PasswordHash: string(hash), PasswordHash: string(hash),
Role: models.UserRoleAdmin,
IsActive: true, IsActive: true,
AuthSource: "builtin", AuthSource: "builtin",
Handle: handle, 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) log.Printf("⚠ Failed to create admin user: %v", err)
return 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) log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
} }
// SeedUsers creates or updates users from the SEED_USERS env var. // SeedUsers creates or updates users from the SEED_USERS env var.
// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user". // Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group.
// Upsert: existing users get their password and role refreshed on every restart. // Upsert: existing users get their password refreshed on every restart.
// Gated to non-production environments. // Gated to non-production environments.
func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) { func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
if cfg.SeedUsers == "" { if cfg.SeedUsers == "" {
@@ -563,16 +560,13 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
parts := strings.SplitN(entry, ":", 3) parts := strings.SplitN(entry, ":", 3)
if len(parts) < 2 { 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 continue
} }
username := strings.ToLower(strings.TrimSpace(parts[0])) username := strings.ToLower(strings.TrimSpace(parts[0]))
password := strings.TrimSpace(parts[1]) password := strings.TrimSpace(parts[1])
role := models.UserRoleUser isAdmin := len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin"
if len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" {
role = models.UserRoleAdmin
}
if username == "" || password == "" { if username == "" || password == "" {
log.Printf("⚠ Seed user skipped (empty username or 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 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) existing, _ := s.Users.GetByUsername(ctx, username)
if existing != nil { if existing != nil {
s.Users.Update(ctx, existing.ID, map[string]interface{}{ s.Users.Update(ctx, existing.ID, map[string]interface{}{
"password_hash": string(hash), "password_hash": string(hash),
"role": role,
"is_active": true, "is_active": true,
}) })
// Probe vault with current password — only destroys if seal is stale
if existing.Handle == "" { if existing.Handle == "" {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username)) handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) 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] cache = uekCache[0]
} }
ProbeAndRepairVault(ctx, s, existing.ID, password, cache) ProbeAndRepairVault(ctx, s, existing.ID, password, cache)
if role == models.UserRoleAdmin { auth.EnsureEveryoneGroup(ctx, s, existing.ID)
ensureAdminsGroupMember(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 continue
} }
@@ -615,7 +608,6 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
Username: username, Username: username,
Email: username + "@switchboard.local", Email: username + "@switchboard.local",
PasswordHash: string(hash), PasswordHash: string(hash),
Role: role,
IsActive: true, IsActive: true,
AuthSource: "builtin", AuthSource: "builtin",
Handle: handle, 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) log.Printf("⚠ Seed user '%s' failed: %v", username, err)
continue continue
} }
if role == models.UserRoleAdmin { auth.EnsureEveryoneGroup(ctx, s, user.ID)
ensureAdminsGroupMember(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. // 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

@@ -40,7 +40,6 @@ func TestJWTGeneration(t *testing.T) {
claims := Claims{ claims := Claims{
UserID: "550e8400-e29b-41d4-a716-446655440000", UserID: "550e8400-e29b-41d4-a716-446655440000",
Email: "test@example.com", Email: "test@example.com",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
@@ -75,9 +74,6 @@ func TestJWTGeneration(t *testing.T) {
if parsed.Email != claims.Email { if parsed.Email != claims.Email {
t.Errorf("Email mismatch: got %s, want %s", 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) { func TestJWTExpired(t *testing.T) {
@@ -86,7 +82,6 @@ func TestJWTExpired(t *testing.T) {
claims := Claims{ claims := Claims{
UserID: "test-user", UserID: "test-user",
Email: "test@example.com", Email: "test@example.com",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)),
@@ -109,7 +104,6 @@ func TestJWTWrongSecret(t *testing.T) {
claims := Claims{ claims := Claims{
UserID: "test-user", UserID: "test-user",
Email: "test@example.com", Email: "test@example.com",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
}, },

View File

@@ -81,17 +81,19 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
// Seed admin user // Seed admin user
adminID := seedInsertReturningID(t, adminID := seedInsertReturningID(t,
`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, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
"ext-admin", "ext-admin@test.com", "$2a$10$test", "admin", "ext-admin", "builtin", "ext-admin", "ext-admin@test.com", "$2a$10$test", "ext-admin", "builtin",
) )
database.SeedEveryoneGroupMember(t, adminID)
database.SeedAdminsGroupMember(t, adminID) 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
userID := seedInsertReturningID(t, userID := seedInsertReturningID(t,
`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, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
"ext-user", "ext-user@test.com", "$2a$10$test", "user", "ext-user", "builtin", "ext-user", "ext-user@test.com", "$2a$10$test", "ext-user", "builtin",
) )
database.SeedEveryoneGroupMember(t, userID)
userToken := makeToken(userID, "ext-user@test.com", "user") userToken := makeToken(userID, "ext-user@test.com", "user")
return &extensionHarness{ return &extensionHarness{

View File

@@ -74,7 +74,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 is_active = true WHERE id = $1"), adminID)
database.SeedAdminsGroupMember(t, adminID) database.SeedAdminsGroupMember(t, adminID)
adminToken := makeToken(adminID, "notif-admin@test.com", "admin") adminToken := makeToken(adminID, "notif-admin@test.com", "admin")

View File

@@ -33,7 +33,6 @@ func NewProfileBootstrapHandler(s store.Stores) *ProfileBootstrapHandler {
// GET /api/v1/profile/bootstrap // GET /api/v1/profile/bootstrap
func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) { func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
role, _ := c.Get("role")
ctx := c.Request.Context() ctx := c.Request.Context()
// ── User profile ──────────────────────── // ── User profile ────────────────────────
@@ -48,28 +47,21 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
"username": user.Username, "username": user.Username,
"display_name": user.DisplayName, "display_name": user.DisplayName,
"email": user.Email, "email": user.Email,
"role": user.Role,
} }
if user.AvatarURL != "" { if user.AvatarURL != "" {
userPayload["avatar"] = user.AvatarURL userPayload["avatar"] = user.AvatarURL
} }
// ── Permissions ───────────────────────── // ── 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) perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
return return
} }
permList = make([]string, 0, len(perms)) permList := make([]string, 0, len(perms))
for p := range perms { for p := range perms {
permList = append(permList, p) permList = append(permList, p)
} }
}
sort.Strings(permList) sort.Strings(permList)
// ── Groups ────────────────────────────── // ── Groups ──────────────────────────────
@@ -77,7 +69,6 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
if groupIDs == nil { if groupIDs == nil {
groupIDs = []string{} groupIDs = []string{}
} }
groupIDs = append(groupIDs, auth.EveryoneGroupID)
// ── Teams ─────────────────────────────── // ── Teams ───────────────────────────────
teams, _ := h.stores.Teams.ListForUser(ctx, userID) teams, _ := h.stores.Teams.ListForUser(ctx, userID)

View File

@@ -36,7 +36,6 @@ type profileResponse struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Role string `json:"role"`
Avatar *string `json:"avatar,omitempty"` Avatar *string `json:"avatar,omitempty"`
Settings map[string]interface{} `json:"settings"` Settings map[string]interface{} `json:"settings"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -84,7 +83,6 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
DisplayName: dn, DisplayName: dn,
Role: user.Role,
Avatar: avatar, Avatar: avatar,
Settings: settings, Settings: settings,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,

View File

@@ -41,11 +41,10 @@ func (h *testHarness) request(method, path, token string, body interface{}) *htt
return w return w
} }
func makeToken(userID, email, role string) string { func makeToken(userID, email, _ string) string {
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
Email: email, Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),

View File

@@ -287,7 +287,6 @@ func main() {
HeaderFingerprint: cfg.MTLSHeaderFingerprint, HeaderFingerprint: cfg.MTLSHeaderFingerprint,
AutoActivate: cfg.MTLSAutoActivate, AutoActivate: cfg.MTLSAutoActivate,
DefaultTeam: cfg.MTLSDefaultTeam, DefaultTeam: cfg.MTLSDefaultTeam,
DefaultRole: cfg.MTLSDefaultRole,
}) })
case auth.ModeOIDC: case auth.ModeOIDC:
var err error var err error
@@ -299,7 +298,6 @@ func main() {
RedirectURL: cfg.OIDCRedirectURL, RedirectURL: cfg.OIDCRedirectURL,
AutoActivate: cfg.OIDCAutoActivate, AutoActivate: cfg.OIDCAutoActivate,
DefaultTeam: cfg.OIDCDefaultTeam, DefaultTeam: cfg.OIDCDefaultTeam,
DefaultRole: cfg.OIDCDefaultRole,
RolesClaim: cfg.OIDCRolesClaim, RolesClaim: cfg.OIDCRolesClaim,
GroupsClaim: cfg.OIDCGroupsClaim, GroupsClaim: cfg.OIDCGroupsClaim,
AdminRole: cfg.OIDCAdminRole, AdminRole: cfg.OIDCAdminRole,

View File

@@ -20,19 +20,17 @@ import (
type Claims struct { type Claims struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// ─── User Status Cache ────────────────────────────────────── // ─── User Status Cache ──────────────────────────────────────
// Short-TTL cache so deactivation and role changes take effect // Short-TTL cache so deactivation takes effect within seconds,
// within seconds, without a DB query on every single request. // without a DB query on every single request.
const userCacheTTL = 30 * time.Second const userCacheTTL = 30 * time.Second
type userCacheEntry struct { type userCacheEntry struct {
isActive bool isActive bool
role string
fetchedAt time.Time fetchedAt time.Time
} }
@@ -64,9 +62,9 @@ func (c *UserStatusCache) get(userID string) (userCacheEntry, bool) {
return e, true 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.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() c.mu.Unlock()
} }
@@ -90,44 +88,43 @@ func (c *UserStatusCache) evictExpired() {
// ─── Shared user verification ─────────────────────────────── // ─── Shared user verification ───────────────────────────────
// verifyUser checks is_active and resolves the current DB role. // verifyUser checks that the user exists and is active.
// Returns (role, nil) on success or ("", error-already-sent) on failure. // Returns true on success or false (with error already sent) on failure.
// Callers must abort the request on error. func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) bool {
func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) (string, bool) {
userID := claims.UserID userID := claims.UserID
if userID == "" { if userID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"})
return "", false return false
} }
return verifyUserByID(c, userID, users, cache) 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. // 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 // Cache hit
if entry, ok := cache.get(userID); ok { if entry, ok := cache.get(userID); ok {
if !entry.isActive { if !entry.isActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"})
return "", false return false
} }
return entry.role, true return true
} }
// Cache miss → DB lookup // Cache miss → DB lookup
user, err := users.GetByID(c.Request.Context(), userID) user, err := users.GetByID(c.Request.Context(), userID)
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) 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 { if !user.IsActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) 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. // 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 return
} }
// Verify user is active and resolve current role from DB // Verify user is active
role, ok := verifyUser(c, claims, users, cache) if !verifyUser(c, claims, users, cache) {
if !ok { return
return // verifyUser already sent the error response
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("email", claims.Email) c.Set("email", claims.Email)
c.Set("role", role) // from DB, not JWT claims
c.Next() c.Next()
} }
} }
@@ -304,14 +299,11 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return return
} }
// Ticket is valid — resolve role from DB (same as JWT path) if !verifyUserByID(c, userID, users, cache) {
role, ok := verifyUserByID(c, userID, users, cache)
if !ok {
return return
} }
c.Set("user_id", userID) c.Set("user_id", userID)
c.Set("role", role)
c.Set("ws_auth", "ticket") c.Set("ws_auth", "ticket")
c.Next() c.Next()
return return
@@ -329,14 +321,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return return
} }
role, ok := verifyUser(c, claims, users, cache) if !verifyUser(c, claims, users, cache) {
if !ok {
return return
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("email", claims.Email) c.Set("email", claims.Email)
c.Set("role", role)
c.Set("ws_auth", "token_legacy") c.Set("ws_auth", "token_legacy")
c.Next() c.Next()
return return
@@ -367,14 +357,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return return
} }
role, ok := verifyUser(c, claims, users, cache) if !verifyUser(c, claims, users, cache) {
if !ok {
return return
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("email", claims.Email) c.Set("email", claims.Email)
c.Set("role", role)
c.Set("ws_auth", "bearer") c.Set("ws_auth", "bearer")
c.Next() c.Next()
} }

View File

@@ -61,27 +61,23 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
return return
} }
// Resolve user role — redirect (not JSON) on any failure. // Check user is active — redirect (not JSON) on failure.
var role string
if entry, hit := cache.get(claims.UserID); hit { if entry, hit := cache.get(claims.UserID); hit {
if !entry.isActive { if !entry.isActive {
redirectToLogin(c, loginPath) redirectToLogin(c, loginPath)
return return
} }
role = entry.role
} else { } else {
user, err := users.GetByID(c.Request.Context(), claims.UserID) user, err := users.GetByID(c.Request.Context(), claims.UserID)
if err != nil || !user.IsActive { if err != nil || !user.IsActive {
redirectToLogin(c, loginPath) redirectToLogin(c, loginPath)
return return
} }
cache.set(claims.UserID, user.IsActive, user.Role) cache.set(claims.UserID, user.IsActive)
role = user.Role
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("email", claims.Email) c.Set("email", claims.Email)
c.Set("role", role)
c.Next() c.Next()
} }
} }

View File

@@ -23,12 +23,9 @@ const (
ScopePersonal = "personal" ScopePersonal = "personal"
) )
// ── Role Constants ────────────────────────── // ── Team Role Constants ─────────────────────
const ( const (
UserRoleUser = "user"
UserRoleAdmin = "admin"
TeamRoleAdmin = "admin" TeamRoleAdmin = "admin"
TeamRoleMember = "member" TeamRoleMember = "member"
) )
@@ -48,7 +45,6 @@ type User struct {
PasswordHash string `json:"-" db:"password_hash"` PasswordHash string `json:"-" db:"password_hash"`
DisplayName string `json:"display_name,omitempty" db:"display_name"` DisplayName string `json:"display_name,omitempty" db:"display_name"`
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"` AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
Role string `json:"role" db:"role"`
IsActive bool `json:"is_active" db:"is_active"` IsActive bool `json:"is_active" db:"is_active"`
Settings JSONMap `json:"settings,omitempty" db:"settings"` Settings JSONMap `json:"settings,omitempty" db:"settings"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log" "log"
"switchboard-core/auth"
"switchboard-core/events" "switchboard-core/events"
"switchboard-core/models" "switchboard-core/models"
"switchboard-core/store" "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) body = fmt.Sprintf("Primary model error: %s", p.Error)
} }
// Notify all admin users // Notify all Admins group members
ctx := context.Background() ctx := context.Background()
admins, _, err := stores.Users.List(ctx, store.ListOptions{Limit: 500}) admins, err := stores.Groups.ListMembers(ctx, auth.AdminsGroupID)
if err != nil { 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 return
} }
for _, u := range admins { for _, u := range admins {
if u.Role != "admin" {
continue
}
n := models.Notification{ n := models.Notification{
UserID: u.ID, UserID: u.UserID,
Type: models.NotifTypeRoleFallback, Type: models.NotifTypeRoleFallback,
Title: title, Title: title,
Body: body, Body: body,
} }
if err := svc.Notify(ctx, &n); err != nil { 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)
} }
} }
} }

View File

@@ -93,9 +93,6 @@ type UserStore interface {
// ── CS2 additions (v0.29.0) ── // ── 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 returns the total number of users.
CountAll(ctx context.Context) (int, error) CountAll(ctx context.Context) (int, error)

View File

@@ -16,10 +16,10 @@ type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} } func NewUserStore() *UserStore { return &UserStore{} }
const userCols = `id, username, email, password_hash, display_name, avatar_url, 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` 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` settings, created_at, updated_at, last_login_at, auth_source, external_id, handle`
func (s *UserStore) Create(ctx context.Context, u *models.User) error { 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" u.AuthSource = "builtin"
} }
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings, auth_source, external_id, handle) 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, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at`, 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, ToJSON(u.Settings), u.AuthSource, u.ExternalID, u.Handle,
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt) ).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 extID, hdl sql.NullString
var sj []byte var sj []byte
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, 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 { &u.AuthSource, &extID, &hdl); err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -173,7 +173,7 @@ func scanOneUser(ctx context.Context, query string, args ...interface{}) (*model
var sj []byte var sj []byte
err := DB.QueryRowContext(ctx, query, args...).Scan( err := DB.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Username, &u.Email, &ph, &dn, &av, &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, &u.AuthSource, &extID, &hdl,
) )
if err != nil { if err != nil {
@@ -233,13 +233,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── // ── 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 { func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE users SET settings = ( UPDATE users SET settings = (

View File

@@ -16,10 +16,10 @@ type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} } func NewUserStore() *UserStore { return &UserStore{} }
const userCols = `id, username, email, password_hash, display_name, avatar_url, 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` 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` settings, created_at, updated_at, last_login_at, auth_source, external_id, handle`
func (s *UserStore) Create(ctx context.Context, u *models.User) error { 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" u.AuthSource = "builtin"
} }
_, err := DB.ExecContext(ctx, ` _, 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) settings, created_at, updated_at, auth_source, external_id, handle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive,
ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt), ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt),
u.AuthSource, u.ExternalID, u.Handle, 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 extID, hdl sql.NullString
var sj []byte var sj []byte
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, 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 { &u.AuthSource, &extID, &hdl); err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -180,7 +180,7 @@ func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface
var sj []byte var sj []byte
err := DB.QueryRowContext(ctx, query, args...).Scan( err := DB.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Username, &u.Email, &ph, &dn, &av, &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, &u.AuthSource, &extID, &hdl,
) )
if err != nil { if err != nil {
@@ -240,13 +240,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── // ── 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 { func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE users SET settings = json_patch( UPDATE users SET settings = json_patch(

View File

@@ -166,7 +166,7 @@ export function createDomains(restClient) {
create: (data) => rc.post('/api/v1/admin/users', data), create: (data) => rc.post('/api/v1/admin/users', data),
del: (id) => rc.del(`/api/v1/admin/users/${id}`), del: (id) => rc.del(`/api/v1/admin/users/${id}`),
resetPassword: (id, pw) => rc.post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }), 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 }), 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`), permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`),
resetVault: (id) => rc.post(`/api/v1/admin/users/${id}/vault/reset`, {}), resetVault: (id) => rc.post(`/api/v1/admin/users/${id}/vault/reset`, {}),

View File

@@ -33,7 +33,7 @@ export default function UsersSection() {
username: form.username.value.trim(), username: form.username.value.trim(),
email: form.email.value.trim(), email: form.email.value.trim(),
password: form.password.value, 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'); if (!data.username || !data.password) return sw.toast('Username and password required', 'error');
try { try {
@@ -52,10 +52,10 @@ export default function UsersSection() {
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
} }
async function changeRole(id, role) { async function toggleAdmin(id, isAdmin) {
try { try {
await sw.api.admin.users.updateRole(id, role); await sw.api.admin.users.updateRole(id, isAdmin);
sw.toast(`Role updated to ${role}`, 'success'); sw.toast(isAdmin ? 'Admin granted' : 'Admin revoked', 'success');
load(); load();
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
} }
@@ -112,8 +112,8 @@ export default function UsersSection() {
<div class="form-group"><label>Username</label><input name="username" placeholder="username" autocomplete="off" /></div> <div class="form-group"><label>Username</label><input name="username" placeholder="username" autocomplete="off" /></div>
<div class="form-group"><label>Email</label><input name="email" placeholder="email@example.com" autocomplete="off" /></div> <div class="form-group"><label>Email</label><input name="email" placeholder="email@example.com" autocomplete="off" /></div>
<div class="form-group"><label>Password</label><input name="password" type="password" placeholder="min 8 chars" autocomplete="new-password" /></div> <div class="form-group"><label>Password</label><input name="password" type="password" placeholder="min 8 chars" autocomplete="new-password" /></div>
<div class="form-group"><label>Role</label> <div class="form-group" style="align-self:flex-end;">
<select name="role"><option value="user">User</option><option value="admin">Admin</option></select> <label class="toggle-label"><input type="checkbox" name="is_admin" /><span class="toggle-track"></span><span>Admin</span></label>
</div> </div>
</div> </div>
<div class="form-row" style="margin-top:8px;gap:8px;"> <div class="form-row" style="margin-top:8px;gap:8px;">
@@ -131,17 +131,11 @@ export default function UsersSection() {
<strong>${u.username}</strong> <strong>${u.username}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span> <span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span>
${' '}${statusBadge(u)} ${' '}${statusBadge(u)}
${' '}<span class="badge ${u.role === 'admin' ? 'badge-admin' : ''}">${u.role}</span>
</div> </div>
<div class="admin-actions-cell"> <div class="admin-actions-cell">
${u.status === 'pending' && html` ${u.status === 'pending' && html`
<button class="btn-small btn-primary" onClick=${() => approveUser(u.id)}>Approve</button> <button class="btn-small btn-primary" onClick=${() => approveUser(u.id)}>Approve</button>
`} `}
<select value=${u.role} onChange=${e => changeRole(u.id, e.target.value)}
style="font-size:11px;padding:2px 4px;">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
${u.is_active ${u.is_active
? html`<button class="btn-small" onClick=${() => toggleActive(u.id, false)}>Disable</button>` ? html`<button class="btn-small" onClick=${() => toggleActive(u.id, false)}>Disable</button>`
: html`<button class="btn-small" onClick=${() => toggleActive(u.id, true)}>Enable</button>` : html`<button class="btn-small" onClick=${() => toggleActive(u.id, true)}>Enable</button>`