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

@@ -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 != "" {