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

@@ -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")