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
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user