Feat admin rbac migration (#1)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #1.
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,11 +160,9 @@ 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 groups
|
||||
@@ -310,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
|
||||
@@ -454,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,
|
||||
@@ -474,6 +466,11 @@ func (p *OIDCProvider) autoProvision(
|
||||
|
||||
log.Printf("[auth/oidc] auto-provisioned user %s (sub=%s, email=%s)", user.ID, sub, email)
|
||||
|
||||
EnsureEveryoneGroup(ctx, stores, user.ID)
|
||||
if p.isIdPAdmin(claims) {
|
||||
AddToAdminsGroup(ctx, stores, user.ID)
|
||||
}
|
||||
|
||||
// Auto-add to default team
|
||||
if p.cfg.DefaultTeam != "" {
|
||||
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {
|
||||
|
||||
@@ -2,29 +2,35 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in
|
||||
// migration 020. Every authenticated user receives its permissions without an
|
||||
// migration 002. Every authenticated user receives its permissions without an
|
||||
// explicit membership row.
|
||||
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// AdminsGroupID is the stable ID of the "Admins" system group seeded in
|
||||
// migration 002. Members receive surface.admin.access and all platform
|
||||
// permissions. Replaces the legacy users.role = 'admin' check.
|
||||
const AdminsGroupID = "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
// Permission constants — domain.action convention.
|
||||
const (
|
||||
PermExtensionUse = "extension.use" // use installed extensions
|
||||
PermExtensionInstall = "extension.install" // install/manage extension packages
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions
|
||||
PermWorkflowSubmit = "workflow.submit" // submit to public workflows
|
||||
PermAdminView = "admin.view" // read-only admin panel access
|
||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||
PermSurfaceAdminAccess = "surface.admin.access" // full admin panel access (replaces role check)
|
||||
PermExtensionUse = "extension.use" // use installed extensions
|
||||
PermExtensionInstall = "extension.install" // install/manage extension packages
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions
|
||||
PermWorkflowSubmit = "workflow.submit" // submit to public workflows
|
||||
PermAdminView = "admin.view" // read-only admin panel access
|
||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||
)
|
||||
|
||||
// AllPermissions is the complete set of valid permission strings.
|
||||
// Used for validation in handlers and rendering checkboxes in admin UI.
|
||||
var AllPermissions = []string{
|
||||
PermSurfaceAdminAccess,
|
||||
PermExtensionUse,
|
||||
PermExtensionInstall,
|
||||
PermWorkflowCreate,
|
||||
@@ -36,19 +42,10 @@ var AllPermissions = []string{
|
||||
// ── Resolution ──────────────────────────────
|
||||
|
||||
// ResolvePermissions returns the effective permission set for a user.
|
||||
// Always includes the Everyone group regardless of membership.
|
||||
// Callers should short-circuit for role=admin before calling this.
|
||||
// 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
|
||||
@@ -62,91 +59,19 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string)
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// ── Token Budget ────────────────────────────
|
||||
|
||||
// TokenBudget holds the resolved ceiling for a user and the time window it covers.
|
||||
type TokenBudget struct {
|
||||
Limit int64
|
||||
Period string // "daily" or "monthly"
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ResolveTokenBudget returns the most restrictive token budget across all of the
|
||||
// user's groups. Returns nil if no group has a budget set (unrestricted).
|
||||
// Callers should skip budget enforcement when providerScope == "personal" (BYOK).
|
||||
func ResolveTokenBudget(ctx context.Context, stores store.Stores, userID string) (*TokenBudget, error) {
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
isNewDay := now.Hour() < 1
|
||||
_ = isNewDay // used by callers for cache invalidation hints if needed
|
||||
|
||||
var daily, monthly *int64
|
||||
for _, g := range groups {
|
||||
if g.TokenBudgetDaily != nil {
|
||||
if daily == nil || *g.TokenBudgetDaily < *daily {
|
||||
daily = g.TokenBudgetDaily
|
||||
}
|
||||
}
|
||||
if g.TokenBudgetMonthly != nil {
|
||||
if monthly == nil || *g.TokenBudgetMonthly < *monthly {
|
||||
monthly = g.TokenBudgetMonthly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the most restrictive window. Daily wins when both are set
|
||||
// and daily is the binding constraint.
|
||||
if daily != nil {
|
||||
return &TokenBudget{Limit: *daily, Period: "daily"}, nil
|
||||
}
|
||||
if monthly != nil {
|
||||
return &TokenBudget{Limit: *monthly, Period: "monthly"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ── Model Allowlist ─────────────────────────
|
||||
|
||||
// ResolveModelAllowlist returns the union of allowed model IDs across all of the
|
||||
// user's groups. Returns nil if the user is unrestricted (any group has a nil
|
||||
// allowlist, which means "no restriction").
|
||||
func ResolveModelAllowlist(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If any group has nil AllowedModels, the user is unrestricted.
|
||||
for _, g := range groups {
|
||||
if g.AllowedModels == nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// All groups have explicit allowlists — union them.
|
||||
allowed := make(map[string]bool)
|
||||
for _, g := range groups {
|
||||
for _, m := range g.AllowedModels {
|
||||
allowed[m] = true
|
||||
}
|
||||
}
|
||||
// No groups at all → fall through to Everyone group check.
|
||||
if len(groups) == 0 {
|
||||
if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil {
|
||||
if everyone.AllowedModels == nil {
|
||||
return nil, nil // Everyone has no restriction
|
||||
}
|
||||
for _, m := range everyone.AllowedModels {
|
||||
allowed[m] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowed) == 0 {
|
||||
return nil, nil // empty allowlists across all groups = unrestricted
|
||||
}
|
||||
return allowed, nil
|
||||
// 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