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

@@ -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
}

View File

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

View File

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

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

View File

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