Feat admin rbac migration (#1)
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / build-and-deploy (push) Has been cancelled
CI/CD / test-sqlite (push) Has been cancelled
CI/CD / test-go-pg (push) Has been cancelled

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:
2026-03-26 17:30:36 +00:00
committed by xcaliber
parent b9180d4184
commit 5836ddad21
37 changed files with 381 additions and 547 deletions

View File

@@ -2,7 +2,62 @@
All notable changes to Switchboard Core are documented here. All notable changes to Switchboard Core are documented here.
## [Unreleased] — v0.1.0 ## [Unreleased] — v0.2.0
### Added
- **Full RBAC**: all authorization flows through group membership and permission
grants. No magic roles, no implicit group membership, no special-casing.
- `surface.admin.access` permission — any group can grant admin panel access
- Admins system group seeded with all platform permissions
- Everyone system group — all users explicitly added on creation
- `EnsureEveryoneGroup()`, `AddToAdminsGroup()`, `RemoveFromAdminsGroup()` helpers
- `SeedAdminsGroupMember()`, `SeedEveryoneGroupMember()` test helpers
- System groups re-seeded after `TruncateAll` in test helper
- OIDC `isIdPAdmin()` — maps IdP role claims to Admins group membership
### Changed
- `RequireAdmin()` / `RequireAdminPage()` check `surface.admin.access` grant
- `RequirePermission()` no longer bypasses for admin role
- `ResolvePermissions()` unions explicit group memberships only (no implicit Everyone)
- All user creation paths (builtin, OIDC, mTLS, admin, bootstrap, seed) add to
Everyone group. Admin users added to Admins group.
- JWT claims no longer include `role` field
- Login response no longer includes `role` in user object
- Profile endpoint no longer returns `role`
- Profile bootstrap resolves permissions from groups (no admin shortcut)
- Middleware auth cache tracks `isActive` only (no role)
- Admin create user accepts `is_admin` bool (not role string)
- Admin update role endpoint accepts `is_admin` bool, manages Admins group directly
- Demotion/deletion safeguards check Admins group member count
- Notifications `RoleFallbackHandler` queries Admins group members
- OIDC syncs Admins group on login (no role column writes)
- Kernel permissions: 6 → 7 (added `surface.admin.access`)
- Admin users UI: role dropdown removed, admin managed through groups
### Removed
- **`users.role` column** — dropped from schema, model, JWT, all handlers
- `UserRoleAdmin`, `UserRoleUser` constants
- `CountByRole()` store method
- `DefaultRole` config for OIDC and mTLS providers
- `SyncAdminsGroupMembership()` (replaced by `AddToAdminsGroup`/`RemoveFromAdminsGroup`)
- OIDC `resolveRole()` (replaced by `isIdPAdmin()`)
- **Token budgets** from groups: columns, `ResolveTokenBudget()`, all store/handler/UI
- **Allowed models** from groups: column, `ResolveModelAllowlist()`, UI
- Admin groups UI: Token Budgets section, Allowed Models section
### Migration notes
- 001_core.sql (both dialects): removed `role` column from users table
- 002_teams.sql (both dialects): added Admins group seed, removed
`token_budget_daily`, `token_budget_monthly`, `allowed_models` columns
- No new migration files — edited in place per pre-MVP policy
---
## [v0.1.0] — 2026-03-26
Forked from chat-switchboard v0.38.5. Gutted to a pure extension platform. Forked from chat-switchboard v0.38.5. Gutted to a pure extension platform.

View File

@@ -36,11 +36,17 @@ platform that extensions build on.
The contract that extensions build against. Three trigger primitives, The contract that extensions build against. Three trigger primitives,
SDK stabilization, and the first rebuilt extension (tasks). SDK stabilization, and the first rebuilt extension (tasks).
- **Trigger system**: time (cron), webhook (inbound HTTP), event (bus subscription) | Step | Status | Description |
|------|--------|-------------|
| Admin → RBAC group | ✅ | `surface.admin.access` permission + Admins system group replaces `role == "admin"` checks. Admin bypass removed from permission middleware. |
| Settings cascade | 🔲 | `user_overridable` flag, RBAC scope auth, resolution chain |
| ICD (API contract) | 🔲 | OpenAPI spec from registered routes, kernel-only endpoint docs |
| Trigger system | 🔲 | Time (cron), webhook (inbound HTTP), event (bus subscription) |
| SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI |
### Remaining v0.2.0 features
- **Event bus subscriptions**: extensions register match expressions at install time - **Event bus subscriptions**: extensions register match expressions at install time
- **SDK contract**: `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`
- **Primitive UI components**: toast, confirm, prompt, dialog (stabilize existing)
- **Theme tokens** exposed to extensions
- **Task extension**: first proof-of-concept — full task system rebuilt as a - **Task extension**: first proof-of-concept — full task system rebuilt as a
Starlark extension using triggers + ext_data + notifications Starlark extension using triggers + ext_data + notifications
- **Settings override model**: RBAC controls scope auth (admin → global, - **Settings override model**: RBAC controls scope auth (admin → global,

View File

@@ -82,7 +82,6 @@ func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result
Username: strings.ToLower(req.Username), Username: strings.ToLower(req.Username),
Email: strings.ToLower(req.Email), Email: strings.ToLower(req.Email),
PasswordHash: string(hash), PasswordHash: string(hash),
Role: models.UserRoleUser,
IsActive: defaultActive, IsActive: defaultActive,
AuthSource: string(ModeBuiltin), AuthSource: string(ModeBuiltin),
Handle: handle, Handle: handle,
@@ -92,6 +91,8 @@ func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result
return nil, err return nil, err
} }
EnsureEveryoneGroup(ctx, stores, user.ID)
return &Result{User: user, IsNewUser: true, VaultHint: req.Password}, nil 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") HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint")
AutoActivate bool // auto-activate new users (default true) AutoActivate bool // auto-activate new users (default true)
DefaultTeam string // team ID for auto-provisioned users (optional) DefaultTeam string // team ID for auto-provisioned users (optional)
DefaultRole string // "user" (default) or "admin"
} }
// MTLSProvider authenticates via client certificate headers injected // MTLSProvider authenticates via client certificate headers injected
@@ -48,9 +47,6 @@ func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider {
if cfg.HeaderFingerprint == "" { if cfg.HeaderFingerprint == "" {
cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint" cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint"
} }
if cfg.DefaultRole == "" {
cfg.DefaultRole = models.UserRoleUser
}
return &MTLSProvider{cfg: cfg} return &MTLSProvider{cfg: cfg}
} }
@@ -134,7 +130,6 @@ func (p *MTLSProvider) autoProvision(
Username: handle, Username: handle,
Email: email, Email: email,
DisplayName: cn, DisplayName: cn,
Role: p.cfg.DefaultRole,
IsActive: true, IsActive: true,
AuthSource: string(ModeMTLS), AuthSource: string(ModeMTLS),
ExternalID: &fingerprint, ExternalID: &fingerprint,
@@ -146,6 +141,7 @@ func (p *MTLSProvider) autoProvision(
} }
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn) 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 // Auto-add to default team if configured
if p.cfg.DefaultTeam != "" { if p.cfg.DefaultTeam != "" {

View File

@@ -96,24 +96,17 @@ func TestMTLSConfig_Defaults(t *testing.T) {
if p.cfg.HeaderFingerprint != "X-SSL-Client-Fingerprint" { if p.cfg.HeaderFingerprint != "X-SSL-Client-Fingerprint" {
t.Errorf("HeaderFingerprint = %q, want X-SSL-Client-Fingerprint", p.cfg.HeaderFingerprint) 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) { func TestMTLSConfig_Custom(t *testing.T) {
p := NewMTLSProvider(MTLSConfig{ p := NewMTLSProvider(MTLSConfig{
HeaderDN: "X-Client-Cert-DN", HeaderDN: "X-Client-Cert-DN",
HeaderVerify: "X-Client-Cert-Verify", HeaderVerify: "X-Client-Cert-Verify",
DefaultRole: "admin",
}) })
if p.cfg.HeaderDN != "X-Client-Cert-DN" { if p.cfg.HeaderDN != "X-Client-Cert-DN" {
t.Errorf("HeaderDN = %q, want X-Client-Cert-DN", p.cfg.HeaderDN) 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) { 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" RedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback"
AutoActivate bool // auto-activate new users (default true) AutoActivate bool // auto-activate new users (default true)
DefaultTeam string // team ID for auto-provisioned users (optional) 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") RolesClaim string // JWT claim for role mapping (default "realm_access.roles")
GroupsClaim string // JWT claim for group sync (default "groups") GroupsClaim string // JWT claim for group sync (default "groups")
AdminRole string // IdP role value that maps to admin (default "admin") AdminRole string // IdP role value that maps to admin (default "admin")
@@ -92,9 +91,6 @@ func NewOIDCProvider(cfg OIDCConfig) (*OIDCProvider, error) {
if cfg.ClientID == "" { if cfg.ClientID == "" {
return nil, fmt.Errorf("OIDC_CLIENT_ID is required") return nil, fmt.Errorf("OIDC_CLIENT_ID is required")
} }
if cfg.DefaultRole == "" {
cfg.DefaultRole = models.UserRoleUser
}
if cfg.RolesClaim == "" { if cfg.RolesClaim == "" {
cfg.RolesClaim = "realm_access.roles" cfg.RolesClaim = "realm_access.roles"
} }
@@ -164,11 +160,9 @@ func (p *OIDCProvider) Authenticate(c *gin.Context, stores store.Stores) (*Resul
return nil, ErrInactive return nil, ErrInactive
} }
// Re-evaluate role on every login // Sync Admins group based on IdP role claim
role := p.resolveRole(claims) if p.isIdPAdmin(claims) {
if role != user.Role { AddToAdminsGroup(ctx, stores, user.ID)
stores.Users.Update(ctx, user.ID, map[string]interface{}{"role": role})
user.Role = role
} }
// Sync groups // Sync groups
@@ -310,14 +304,14 @@ func (p *OIDCProvider) validateToken(tokenString string) (*oidcClaims, error) {
return claims, nil return claims, nil
} }
func (p *OIDCProvider) resolveRole(claims *oidcClaims) string { // isIdPAdmin checks whether the OIDC claims include the configured admin role.
// Check realm_access.roles for admin role func (p *OIDCProvider) isIdPAdmin(claims *oidcClaims) bool {
for _, role := range claims.RealmAccess.Roles { for _, role := range claims.RealmAccess.Roles {
if role == p.cfg.AdminRole { 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 // syncGroups compares IdP groups claim with internal group memberships
@@ -454,14 +448,12 @@ func (p *OIDCProvider) autoProvision(
displayName = username displayName = username
} }
role := p.resolveRole(claims)
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username)) handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username))
user := &models.User{ user := &models.User{
Username: strings.ToLower(username), Username: strings.ToLower(username),
Email: strings.ToLower(email), Email: strings.ToLower(email),
DisplayName: displayName, DisplayName: displayName,
Role: role,
IsActive: true, IsActive: true,
AuthSource: string(ModeOIDC), AuthSource: string(ModeOIDC),
ExternalID: &sub, 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) 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 // Auto-add to default team
if p.cfg.DefaultTeam != "" { if p.cfg.DefaultTeam != "" {
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil { if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {

View File

@@ -2,29 +2,35 @@ package auth
import ( import (
"context" "context"
"time"
"switchboard-core/store" "switchboard-core/store"
) )
// EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in // 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. // explicit membership row.
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001" 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. // Permission constants — domain.action convention.
const ( const (
PermExtensionUse = "extension.use" // use installed extensions PermSurfaceAdminAccess = "surface.admin.access" // full admin panel access (replaces role check)
PermExtensionInstall = "extension.install" // install/manage extension packages PermExtensionUse = "extension.use" // use installed extensions
PermWorkflowCreate = "workflow.create" // create workflow definitions PermExtensionInstall = "extension.install" // install/manage extension packages
PermWorkflowSubmit = "workflow.submit" // submit to public workflows PermWorkflowCreate = "workflow.create" // create workflow definitions
PermAdminView = "admin.view" // read-only admin panel access PermWorkflowSubmit = "workflow.submit" // submit to public workflows
PermTokenUnlimited = "token.unlimited" // bypass token budgets PermAdminView = "admin.view" // read-only admin panel access
PermTokenUnlimited = "token.unlimited" // bypass token budgets
) )
// AllPermissions is the complete set of valid permission strings. // AllPermissions is the complete set of valid permission strings.
// Used for validation in handlers and rendering checkboxes in admin UI. // Used for validation in handlers and rendering checkboxes in admin UI.
var AllPermissions = []string{ var AllPermissions = []string{
PermSurfaceAdminAccess,
PermExtensionUse, PermExtensionUse,
PermExtensionInstall, PermExtensionInstall,
PermWorkflowCreate, PermWorkflowCreate,
@@ -36,19 +42,10 @@ var AllPermissions = []string{
// ── Resolution ────────────────────────────── // ── Resolution ──────────────────────────────
// ResolvePermissions returns the effective permission set for a user. // ResolvePermissions returns the effective permission set for a user.
// Always includes the Everyone group regardless of membership. // Unions permissions from all groups the user is a member of (including Everyone).
// Callers should short-circuit for role=admin before calling this.
func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) { func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
perms := make(map[string]bool) 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) groups, err := stores.Groups.ListForUser(ctx, userID)
if err != nil { if err != nil {
return perms, err return perms, err
@@ -62,91 +59,19 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string)
return perms, nil return perms, nil
} }
// ── Token Budget ──────────────────────────── // EnsureEveryoneGroup adds a user to the Everyone group (idempotent).
// Called on every user creation path so that Everyone membership is explicit.
// TokenBudget holds the resolved ceiling for a user and the time window it covers. func EnsureEveryoneGroup(ctx context.Context, stores store.Stores, userID string) {
type TokenBudget struct { _ = stores.Groups.AddMember(ctx, EveryoneGroupID, userID, userID)
Limit int64
Period string // "daily" or "monthly"
} }
// ResolveTokenBudget returns the most restrictive token budget across all of the // AddToAdminsGroup adds a user to the Admins group (idempotent).
// user's groups. Returns nil if no group has a budget set (unrestricted). func AddToAdminsGroup(ctx context.Context, stores store.Stores, userID string) {
// Callers should skip budget enforcement when providerScope == "personal" (BYOK). _ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID)
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
} }
// ── Model Allowlist ───────────────────────── // RemoveFromAdminsGroup removes a user from the Admins group.
func RemoveFromAdminsGroup(ctx context.Context, stores store.Stores, userID string) {
// ResolveModelAllowlist returns the union of allowed model IDs across all of the _ = stores.Groups.RemoveMember(ctx, AdminsGroupID, userID)
// 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
} }

View File

@@ -62,7 +62,6 @@ type Config struct {
MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint" MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint"
MTLSAutoActivate bool // auto-activate new users (default true) MTLSAutoActivate bool // auto-activate new users (default true)
MTLSDefaultTeam string // team ID for auto-provisioned users (optional) MTLSDefaultTeam string // team ID for auto-provisioned users (optional)
MTLSDefaultRole string // "user" (default) or "admin"
// OIDC (v0.24.1) // OIDC (v0.24.1)
OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard" OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard"
@@ -72,7 +71,6 @@ type Config struct {
OIDCRedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback" OIDCRedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback"
OIDCAutoActivate bool // auto-activate new users (default true) OIDCAutoActivate bool // auto-activate new users (default true)
OIDCDefaultTeam string // team ID for auto-provisioned users (optional) OIDCDefaultTeam string // team ID for auto-provisioned users (optional)
OIDCDefaultRole string // "user" (default) or "admin"
OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles") OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles")
OIDCGroupsClaim string // JWT claim for group sync (default "groups") OIDCGroupsClaim string // JWT claim for group sync (default "groups")
OIDCAdminRole string // IdP role value that maps to admin (default "admin") OIDCAdminRole string // IdP role value that maps to admin (default "admin")
@@ -118,7 +116,6 @@ func Load() *Config {
MTLSHeaderFingerprint: getEnv("MTLS_HEADER_FINGERPRINT", "X-SSL-Client-Fingerprint"), MTLSHeaderFingerprint: getEnv("MTLS_HEADER_FINGERPRINT", "X-SSL-Client-Fingerprint"),
MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true), MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true),
MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""), MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""),
MTLSDefaultRole: getEnv("MTLS_DEFAULT_ROLE", "user"),
// OIDC // OIDC
OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""), OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""),
@@ -128,7 +125,6 @@ func Load() *Config {
OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""), OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""),
OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true), OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true),
OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""), OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""),
OIDCDefaultRole: getEnv("OIDC_DEFAULT_ROLE", "user"),
OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"), OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"),
OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"), OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"),
OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"), OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"),

View File

@@ -23,8 +23,6 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT, password_hash TEXT,
display_name VARCHAR(100), display_name VARCHAR(100),
avatar_url TEXT, avatar_url TEXT,
role VARCHAR(20) DEFAULT 'user'
CHECK (role IN ('user', 'admin')),
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb, settings JSONB DEFAULT '{}'::jsonb,
auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin' auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin'

View File

@@ -44,9 +44,6 @@ CREATE TABLE IF NOT EXISTS groups (
source VARCHAR(20) NOT NULL DEFAULT 'manual' source VARCHAR(20) NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual', 'oidc', 'system')), CHECK (source IN ('manual', 'oidc', 'system')),
permissions JSONB NOT NULL DEFAULT '[]'::jsonb, permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
token_budget_daily BIGINT,
token_budget_monthly BIGINT,
allowed_models JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT groups_scope_team CHECK ( CONSTRAINT groups_scope_team CHECK (
@@ -83,3 +80,16 @@ VALUES (
'["extension.use","workflow.submit"]'::jsonb '["extension.use","workflow.submit"]'::jsonb
) )
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Seed Admins group — members receive full platform permissions.
-- Replaces the legacy users.role = 'admin' authorization check.
-- Membership is managed by bootstrap/seed logic at runtime.
INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
VALUES (
'00000000-0000-0000-0000-000000000002',
'Admins',
'Full platform access — replaces legacy admin role.',
'global', NULL, 'system',
'["surface.admin.access","extension.use","extension.install","workflow.create","workflow.submit","admin.view","token.unlimited"]'::jsonb
)
ON CONFLICT (id) DO NOTHING;

View File

@@ -12,7 +12,6 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT, password_hash TEXT,
display_name TEXT, display_name TEXT,
avatar_url TEXT, avatar_url TEXT,
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin')),
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}', settings TEXT DEFAULT '{}',
auth_source TEXT NOT NULL DEFAULT 'builtin', auth_source TEXT NOT NULL DEFAULT 'builtin',

View File

@@ -40,9 +40,6 @@ CREATE TABLE IF NOT EXISTS groups (
created_by TEXT REFERENCES users(id), created_by TEXT REFERENCES users(id),
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc', 'system')), source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc', 'system')),
permissions TEXT NOT NULL DEFAULT '[]', permissions TEXT NOT NULL DEFAULT '[]',
token_budget_daily INTEGER,
token_budget_monthly INTEGER,
allowed_models TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
); );
@@ -74,3 +71,15 @@ VALUES (
'global', NULL, 'system', 'global', NULL, 'system',
'["extension.use","workflow.submit"]' '["extension.use","workflow.submit"]'
); );
-- Seed Admins group — members receive full platform permissions.
-- Replaces the legacy users.role = 'admin' authorization check.
-- Membership is managed by bootstrap/seed logic at runtime.
INSERT OR IGNORE INTO groups (id, name, description, scope, created_by, source, permissions)
VALUES (
'00000000-0000-0000-0000-000000000002',
'Admins',
'Full platform access — replaces legacy admin role.',
'global', NULL, 'system',
'["surface.admin.access","extension.use","extension.install","workflow.create","workflow.submit","admin.view","token.unlimited"]'
);

View File

@@ -304,6 +304,31 @@ func TruncateAll(t *testing.T) {
} }
} }
// Re-seed system groups (truncated above).
if IsSQLite() {
DB.Exec(`INSERT OR IGNORE INTO groups (id, name, description, scope, created_by, source, permissions)
VALUES ('00000000-0000-0000-0000-000000000001', 'Everyone',
'Implicit group — all authenticated users receive these permissions.',
'global', NULL, 'system', '["extension.use","workflow.submit"]')`)
DB.Exec(`INSERT OR IGNORE INTO groups (id, name, description, scope, created_by, source, permissions)
VALUES ('00000000-0000-0000-0000-000000000002', 'Admins',
'Full platform access — replaces legacy admin role.',
'global', NULL, 'system',
'["surface.admin.access","extension.use","extension.install","workflow.create","workflow.submit","admin.view","token.unlimited"]')`)
} else {
DB.Exec(`INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
VALUES ('00000000-0000-0000-0000-000000000001', 'Everyone',
'Implicit group — all authenticated users receive these permissions.',
'global', NULL, 'system', '["extension.use","workflow.submit"]'::jsonb)
ON CONFLICT (id) DO NOTHING`)
DB.Exec(`INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
VALUES ('00000000-0000-0000-0000-000000000002', 'Admins',
'Full platform access — replaces legacy admin role.',
'global', NULL, 'system',
'["surface.admin.access","extension.use","extension.install","workflow.create","workflow.submit","admin.view","token.unlimited"]'::jsonb)
ON CONFLICT (id) DO NOTHING`)
}
// Re-seed default config rows. // Re-seed default config rows.
if IsSQLite() { if IsSQLite() {
DB.Exec(` DB.Exec(`
@@ -350,33 +375,69 @@ func TruncateAll(t *testing.T) {
} }
} }
// SeedTestUser creates a test user and returns the user ID. // SeedTestUser creates a test user, adds to Everyone group, and returns the user ID.
func SeedTestUser(t *testing.T, username, email string) string { func SeedTestUser(t *testing.T, username, email string) string {
t.Helper() t.Helper()
handle := strings.ToLower(strings.ReplaceAll(username, " ", "-")) handle := strings.ToLower(strings.ReplaceAll(username, " ", "-"))
if IsSQLite() { if IsSQLite() {
id := uuid.New().String() id := uuid.New().String()
_, err := DB.Exec(` _, err := DB.Exec(`
INSERT INTO users (id, username, email, password_hash, role, auth_source, handle) INSERT INTO users (id, username, email, password_hash, auth_source, handle)
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', ?) VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', ?)
`, id, username, email, handle) `, id, username, email, handle)
if err != nil { if err != nil {
t.Fatalf("SeedTestUser: %v", err) t.Fatalf("SeedTestUser: %v", err)
} }
SeedEveryoneGroupMember(t, id)
return id return id
} }
var id string var id string
err := DB.QueryRow(` err := DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role, auth_source, handle) INSERT INTO users (username, email, password_hash, auth_source, handle)
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', $3) VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', $3)
RETURNING id RETURNING id
`, username, email, handle).Scan(&id) `, username, email, handle).Scan(&id)
if err != nil { if err != nil {
t.Fatalf("SeedTestUser: %v", err) t.Fatalf("SeedTestUser: %v", err)
} }
SeedEveryoneGroupMember(t, id)
return id return id
} }
// SeedEveryoneGroupMember adds a user to the Everyone system group in test databases.
func SeedEveryoneGroupMember(t *testing.T, userID string) {
t.Helper()
const everyoneGroupID = "00000000-0000-0000-0000-000000000001"
if IsSQLite() {
DB.Exec(`INSERT OR IGNORE INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`,
uuid.New().String(), everyoneGroupID, userID, userID)
return
}
DB.Exec(`INSERT INTO group_members (group_id, user_id, added_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
everyoneGroupID, userID, userID)
}
// SeedAdminsGroupMember adds a user to the Admins system group in test databases.
// Call after creating a user with role='admin' so they receive surface.admin.access
// through the normal permission resolution path.
func SeedAdminsGroupMember(t *testing.T, userID string) {
t.Helper()
const adminsGroupID = "00000000-0000-0000-0000-000000000002"
if IsSQLite() {
_, err := DB.Exec(`INSERT OR IGNORE INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`,
uuid.New().String(), adminsGroupID, userID, userID)
if err != nil {
t.Fatalf("SeedAdminsGroupMember: %v", err)
}
return
}
_, err := DB.Exec(`INSERT INTO group_members (group_id, user_id, added_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
adminsGroupID, userID, userID)
if err != nil {
t.Fatalf("SeedAdminsGroupMember: %v", err)
}
}
// SeedTestChannel creates a test channel owned by userID and returns the channel ID. // SeedTestChannel creates a test channel owned by userID and returns the channel ID.
func SeedTestChannel(t *testing.T, userID, title string) string { func SeedTestChannel(t *testing.T, userID, title string) string {
t.Helper() t.Helper()

View File

@@ -69,29 +69,25 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"` Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"` Password string `json:"password" binding:"required,min=8"`
Role string `json:"role"` IsAdmin bool `json:"is_admin"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
ctx := c.Request.Context()
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
role := models.UserRoleUser
if req.Role == models.UserRoleAdmin {
role = models.UserRoleAdmin
}
user := &models.User{ user := &models.User{
Username: strings.ToLower(req.Username), Username: strings.ToLower(req.Username),
Email: strings.ToLower(req.Email), Email: strings.ToLower(req.Email),
PasswordHash: string(hash), PasswordHash: string(hash),
Role: role,
IsActive: true, IsActive: true,
Handle: auth.UniqueHandle(c.Request.Context(), h.stores.Users, models.HandleFromName(req.Username)), Handle: auth.UniqueHandle(ctx, h.stores.Users, models.HandleFromName(req.Username)),
} }
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil { if err := h.stores.Users.Create(ctx, user); err != nil {
if database.IsUniqueViolation(err) { if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "username or email already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "username or email already exists"})
return return
@@ -100,46 +96,55 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
return return
} }
auth.EnsureEveryoneGroup(ctx, h.stores, user.ID)
if req.IsAdmin {
auth.AddToAdminsGroup(ctx, h.stores, user.ID)
}
h.auditLog(c, "user.create", "user", user.ID, nil) h.auditLog(c, "user.create", "user", user.ID, nil)
c.JSON(http.StatusCreated, user) c.JSON(http.StatusCreated, user)
} }
func (h *AdminHandler) UpdateUserRole(c *gin.Context) { func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
ctx := c.Request.Context()
var req struct { var req struct {
Role string `json:"role" binding:"required"` IsAdmin bool `json:"is_admin"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
// Guard: prevent demoting the last admin if _, err := h.stores.Users.GetByID(ctx, id); err != nil {
if req.Role != models.UserRoleAdmin { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
user, err := h.stores.Users.GetByID(c.Request.Context(), id)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if user.Role == models.UserRoleAdmin {
adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin)
if adminCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot demote the last admin"})
return
}
}
}
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
return return
} }
h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role}) if !req.IsAdmin {
// Guard: prevent removing the last admin from Admins group.
members, _ := h.stores.Groups.ListMembers(ctx, auth.AdminsGroupID)
isMember := false
for _, m := range members {
if m.UserID == id {
isMember = true
break
}
}
if isMember && len(members) <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last admin"})
return
}
auth.RemoveFromAdminsGroup(ctx, h.stores, id)
} else {
auth.AddToAdminsGroup(ctx, h.stores, id)
}
h.auditLog(c, "user.role_change", "user", id, gin.H{"is_admin": req.IsAdmin})
if h.onUserChanged != nil { if h.onUserChanged != nil {
h.onUserChanged(id) h.onUserChanged(id)
} }
c.JSON(http.StatusOK, gin.H{"message": "role updated"}) c.JSON(http.StatusOK, gin.H{"message": "admin status updated"})
} }
func (h *AdminHandler) ToggleUserActive(c *gin.Context) { func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
@@ -248,18 +253,22 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
// Guard: prevent deleting the last admin // Guard: prevent deleting the last admin
user, err := h.stores.Users.GetByID(c.Request.Context(), id) if _, err := h.stores.Users.GetByID(c.Request.Context(), id); err != nil {
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return return
} }
if user.Role == models.UserRoleAdmin { members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID)
adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin) isAdmin := false
if adminCount <= 1 { for _, m := range members {
c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"}) if m.UserID == id {
return isAdmin = true
break
} }
} }
if isAdmin && len(members) <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"})
return
}
// Destroy vault and personal BYOK keys before deleting the user row // Destroy vault and personal BYOK keys before deleting the user row
h.destroyVault(c, id) h.destroyVault(c, id)

View File

@@ -32,7 +32,6 @@ const bcryptCost = 12
type Claims struct { type Claims struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -295,7 +294,6 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
accessClaims := Claims{ accessClaims := Claims{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -327,7 +325,6 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
"username": user.Username, "username": user.Username,
"email": user.Email, "email": user.Email,
"display_name": user.DisplayName, "display_name": user.DisplayName,
"role": user.Role,
"handle": user.Handle, "handle": user.Handle,
"auth_source": user.AuthSource, "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) existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername)
if existing != nil { if existing != nil {
// Update password and ensure admin role // Update password
s.Users.Update(ctx, existing.ID, map[string]interface{}{ s.Users.Update(ctx, existing.ID, map[string]interface{}{
"password_hash": string(hash), "password_hash": string(hash),
"role": models.UserRoleAdmin,
"is_active": true, "is_active": true,
}) })
// If the actual password changed, the vault seal is stale. Probe it // If the actual password changed, the vault seal is stale. Probe it
@@ -507,6 +503,8 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC
cache = uekCache[0] cache = uekCache[0]
} }
ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache) ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache)
auth.EnsureEveryoneGroup(ctx, s, existing.ID)
auth.AddToAdminsGroup(ctx, s, existing.ID)
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername) log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return return
} }
@@ -521,7 +519,6 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC
Username: strings.ToLower(cfg.AdminUsername), Username: strings.ToLower(cfg.AdminUsername),
Email: strings.ToLower(email), Email: strings.ToLower(email),
PasswordHash: string(hash), PasswordHash: string(hash),
Role: models.UserRoleAdmin,
IsActive: true, IsActive: true,
AuthSource: "builtin", AuthSource: "builtin",
Handle: handle, Handle: handle,
@@ -531,12 +528,14 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC
log.Printf("⚠ Failed to create admin user: %v", err) log.Printf("⚠ Failed to create admin user: %v", err)
return return
} }
auth.EnsureEveryoneGroup(ctx, s, user.ID)
auth.AddToAdminsGroup(ctx, s, user.ID)
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername) log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
} }
// SeedUsers creates or updates users from the SEED_USERS env var. // SeedUsers creates or updates users from the SEED_USERS env var.
// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user". // Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group.
// Upsert: existing users get their password and role refreshed on every restart. // Upsert: existing users get their password refreshed on every restart.
// Gated to non-production environments. // Gated to non-production environments.
func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) { func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
if cfg.SeedUsers == "" { if cfg.SeedUsers == "" {
@@ -561,16 +560,13 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
parts := strings.SplitN(entry, ":", 3) parts := strings.SplitN(entry, ":", 3)
if len(parts) < 2 { 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 continue
} }
username := strings.ToLower(strings.TrimSpace(parts[0])) username := strings.ToLower(strings.TrimSpace(parts[0]))
password := strings.TrimSpace(parts[1]) password := strings.TrimSpace(parts[1])
role := models.UserRoleUser isAdmin := len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin"
if len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" {
role = models.UserRoleAdmin
}
if username == "" || password == "" { if username == "" || password == "" {
log.Printf("⚠ Seed user skipped (empty username or password)") log.Printf("⚠ Seed user skipped (empty username or password)")
@@ -583,15 +579,13 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
continue 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) existing, _ := s.Users.GetByUsername(ctx, username)
if existing != nil { if existing != nil {
s.Users.Update(ctx, existing.ID, map[string]interface{}{ s.Users.Update(ctx, existing.ID, map[string]interface{}{
"password_hash": string(hash), "password_hash": string(hash),
"role": role,
"is_active": true, "is_active": true,
}) })
// Probe vault with current password — only destroys if seal is stale
if existing.Handle == "" { if existing.Handle == "" {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username)) handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
@@ -601,7 +595,11 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
cache = uekCache[0] cache = uekCache[0]
} }
ProbeAndRepairVault(ctx, s, existing.ID, password, cache) ProbeAndRepairVault(ctx, s, existing.ID, password, cache)
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role) auth.EnsureEveryoneGroup(ctx, s, existing.ID)
if isAdmin {
auth.AddToAdminsGroup(ctx, s, existing.ID)
}
log.Printf(" 🌱 Seed user '%s' updated (admin=%v)", username, isAdmin)
continue continue
} }
@@ -610,7 +608,6 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
Username: username, Username: username,
Email: username + "@switchboard.local", Email: username + "@switchboard.local",
PasswordHash: string(hash), PasswordHash: string(hash),
Role: role,
IsActive: true, IsActive: true,
AuthSource: "builtin", AuthSource: "builtin",
Handle: handle, Handle: handle,
@@ -620,7 +617,11 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
log.Printf("⚠ Seed user '%s' failed: %v", username, err) log.Printf("⚠ Seed user '%s' failed: %v", username, err)
continue continue
} }
log.Printf(" 🌱 Seed user '%s' created (role=%s, active=true)", username, role) auth.EnsureEveryoneGroup(ctx, s, user.ID)
if isAdmin {
auth.AddToAdminsGroup(ctx, s, user.ID)
}
log.Printf(" 🌱 Seed user '%s' created (admin=%v, active=true)", username, isAdmin)
} }
} }

View File

@@ -40,7 +40,6 @@ func TestJWTGeneration(t *testing.T) {
claims := Claims{ claims := Claims{
UserID: "550e8400-e29b-41d4-a716-446655440000", UserID: "550e8400-e29b-41d4-a716-446655440000",
Email: "test@example.com", Email: "test@example.com",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
@@ -75,9 +74,6 @@ func TestJWTGeneration(t *testing.T) {
if parsed.Email != claims.Email { if parsed.Email != claims.Email {
t.Errorf("Email mismatch: got %s, want %s", parsed.Email, claims.Email) t.Errorf("Email mismatch: got %s, want %s", parsed.Email, claims.Email)
} }
if parsed.Role != claims.Role {
t.Errorf("Role mismatch: got %s, want %s", parsed.Role, claims.Role)
}
} }
func TestJWTExpired(t *testing.T) { func TestJWTExpired(t *testing.T) {
@@ -86,7 +82,6 @@ func TestJWTExpired(t *testing.T) {
claims := Claims{ claims := Claims{
UserID: "test-user", UserID: "test-user",
Email: "test@example.com", Email: "test@example.com",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)),
@@ -109,7 +104,6 @@ func TestJWTWrongSecret(t *testing.T) {
claims := Claims{ claims := Claims{
UserID: "test-user", UserID: "test-user",
Email: "test@example.com", Email: "test@example.com",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
}, },

View File

@@ -73,7 +73,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
// Admin extension endpoints // Admin extension endpoints
admin := api.Group("/admin") admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin()) admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores))
admin.GET("/extensions", extH.AdminListExtensions) admin.GET("/extensions", extH.AdminListExtensions)
admin.POST("/extensions", extH.AdminInstallExtension) admin.POST("/extensions", extH.AdminInstallExtension)
admin.PUT("/extensions/:id", extH.AdminUpdateExtension) admin.PUT("/extensions/:id", extH.AdminUpdateExtension)
@@ -81,16 +81,19 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
// Seed admin user // Seed admin user
adminID := seedInsertReturningID(t, adminID := seedInsertReturningID(t,
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, `INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
"ext-admin", "ext-admin@test.com", "$2a$10$test", "admin", "ext-admin", "builtin", "ext-admin", "ext-admin@test.com", "$2a$10$test", "ext-admin", "builtin",
) )
database.SeedEveryoneGroupMember(t, adminID)
database.SeedAdminsGroupMember(t, adminID)
adminToken := makeToken(adminID, "ext-admin@test.com", "admin") adminToken := makeToken(adminID, "ext-admin@test.com", "admin")
// Seed regular user // Seed regular user
userID := seedInsertReturningID(t, userID := seedInsertReturningID(t,
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, `INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
"ext-user", "ext-user@test.com", "$2a$10$test", "user", "ext-user", "builtin", "ext-user", "ext-user@test.com", "$2a$10$test", "ext-user", "builtin",
) )
database.SeedEveryoneGroupMember(t, userID)
userToken := makeToken(userID, "ext-user@test.com", "user") userToken := makeToken(userID, "ext-user@test.com", "user")
return &extensionHarness{ return &extensionHarness{

View File

@@ -25,15 +25,9 @@ type createGroupRequest struct {
} }
type updateGroupRequest struct { type updateGroupRequest struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Permissions *[]string `json:"permissions,omitempty"` Permissions *[]string `json:"permissions,omitempty"`
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"`
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"`
AllowedModels *[]string `json:"allowed_models,omitempty"`
ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"`
ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"`
ClearAllowedModels bool `json:"clear_allowed_models,omitempty"`
} }
type addGroupMemberRequest struct { type addGroupMemberRequest struct {
@@ -144,15 +138,9 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
} }
patch := models.GroupPatch{ patch := models.GroupPatch{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Permissions: req.Permissions, Permissions: req.Permissions,
TokenBudgetDaily: req.TokenBudgetDaily,
TokenBudgetMonthly: req.TokenBudgetMonthly,
AllowedModels: req.AllowedModels,
ClearBudgetDaily: req.ClearBudgetDaily,
ClearBudgetMonthly: req.ClearBudgetMonthly,
ClearAllowedModels: req.ClearAllowedModels,
} }
// Validate permissions if provided // Validate permissions if provided

View File

@@ -65,7 +65,7 @@ func setupNotifHarness(t *testing.T) *notifHarness {
// Admin broadcast route (v0.28.6) // Admin broadcast route (v0.28.6)
admin := api.Group("/admin") admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache)) admin.Use(middleware.Auth(cfg, stores.Users, userCache))
admin.Use(middleware.RequireAdmin()) admin.Use(middleware.RequireAdmin(stores))
admin.POST("/notifications/broadcast", notifH.Broadcast) admin.POST("/notifications/broadcast", notifH.Broadcast)
// Set up notification service singleton for Broadcast handler // Set up notification service singleton for Broadcast handler
@@ -74,7 +74,8 @@ func setupNotifHarness(t *testing.T) *notifHarness {
// Seed admin // Seed admin
adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com") adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID) database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), adminID)
database.SeedAdminsGroupMember(t, adminID)
adminToken := makeToken(adminID, "notif-admin@test.com", "admin") adminToken := makeToken(adminID, "notif-admin@test.com", "admin")
// Seed regular user // Seed regular user

View File

@@ -33,7 +33,6 @@ func NewProfileBootstrapHandler(s store.Stores) *ProfileBootstrapHandler {
// GET /api/v1/profile/bootstrap // GET /api/v1/profile/bootstrap
func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) { func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
role, _ := c.Get("role")
ctx := c.Request.Context() ctx := c.Request.Context()
// ── User profile ──────────────────────── // ── User profile ────────────────────────
@@ -48,27 +47,20 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
"username": user.Username, "username": user.Username,
"display_name": user.DisplayName, "display_name": user.DisplayName,
"email": user.Email, "email": user.Email,
"role": user.Role,
} }
if user.AvatarURL != "" { if user.AvatarURL != "" {
userPayload["avatar"] = user.AvatarURL userPayload["avatar"] = user.AvatarURL
} }
// ── Permissions ───────────────────────── // ── Permissions ─────────────────────────
var permList []string perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
if role == "admin" { if err != nil {
permList = make([]string, len(auth.AllPermissions)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
copy(permList, auth.AllPermissions) return
} else { }
perms, err := auth.ResolvePermissions(ctx, h.stores, userID) permList := make([]string, 0, len(perms))
if err != nil { for p := range perms {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) permList = append(permList, p)
return
}
permList = make([]string, 0, len(perms))
for p := range perms {
permList = append(permList, p)
}
} }
sort.Strings(permList) sort.Strings(permList)
@@ -77,7 +69,6 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
if groupIDs == nil { if groupIDs == nil {
groupIDs = []string{} groupIDs = []string{}
} }
groupIDs = append(groupIDs, auth.EveryoneGroupID)
// ── Teams ─────────────────────────────── // ── Teams ───────────────────────────────
teams, _ := h.stores.Teams.ListForUser(ctx, userID) teams, _ := h.stores.Teams.ListForUser(ctx, userID)

View File

@@ -36,7 +36,6 @@ type profileResponse struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Role string `json:"role"`
Avatar *string `json:"avatar,omitempty"` Avatar *string `json:"avatar,omitempty"`
Settings map[string]interface{} `json:"settings"` Settings map[string]interface{} `json:"settings"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -84,7 +83,6 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
DisplayName: dn, DisplayName: dn,
Role: user.Role,
Avatar: avatar, Avatar: avatar,
Settings: settings, Settings: settings,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,

View File

@@ -41,11 +41,10 @@ func (h *testHarness) request(method, path, token string, body interface{}) *htt
return w return w
} }
func makeToken(userID, email, role string) string { func makeToken(userID, email, _ string) string {
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
Email: email, Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),

View File

@@ -287,7 +287,6 @@ func main() {
HeaderFingerprint: cfg.MTLSHeaderFingerprint, HeaderFingerprint: cfg.MTLSHeaderFingerprint,
AutoActivate: cfg.MTLSAutoActivate, AutoActivate: cfg.MTLSAutoActivate,
DefaultTeam: cfg.MTLSDefaultTeam, DefaultTeam: cfg.MTLSDefaultTeam,
DefaultRole: cfg.MTLSDefaultRole,
}) })
case auth.ModeOIDC: case auth.ModeOIDC:
var err error var err error
@@ -299,7 +298,6 @@ func main() {
RedirectURL: cfg.OIDCRedirectURL, RedirectURL: cfg.OIDCRedirectURL,
AutoActivate: cfg.OIDCAutoActivate, AutoActivate: cfg.OIDCAutoActivate,
DefaultTeam: cfg.OIDCDefaultTeam, DefaultTeam: cfg.OIDCDefaultTeam,
DefaultRole: cfg.OIDCDefaultRole,
RolesClaim: cfg.OIDCRolesClaim, RolesClaim: cfg.OIDCRolesClaim,
GroupsClaim: cfg.OIDCGroupsClaim, GroupsClaim: cfg.OIDCGroupsClaim,
AdminRole: cfg.OIDCAdminRole, AdminRole: cfg.OIDCAdminRole,
@@ -513,7 +511,7 @@ func main() {
// ── Admin routes ──────────────────────── // ── Admin routes ────────────────────────
admin := api.Group("/admin") admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache)) admin.Use(middleware.Auth(cfg, stores.Users, userCache))
admin.Use(middleware.RequireAdmin()) admin.Use(middleware.RequireAdmin(stores))
admin.Use(middleware.ValidatePathParams()) admin.Use(middleware.ValidatePathParams())
{ {
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore) adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore)
@@ -693,7 +691,7 @@ func main() {
Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache), Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache),
Admin: []gin.HandlerFunc{ Admin: []gin.HandlerFunc{
middleware.AuthOrRedirect(cfg, stores.Users, userCache), middleware.AuthOrRedirect(cfg, stores.Users, userCache),
middleware.RequireAdminPage(), middleware.RequireAdminPage(stores),
}, },
Session: middleware.AuthOrRedirect(cfg, stores.Users, userCache), // TODO: session middleware for workflow visitors (v0.2.0) Session: middleware.AuthOrRedirect(cfg, stores.Users, userCache), // TODO: session middleware for workflow visitors (v0.2.0)
}) })

View File

@@ -4,14 +4,19 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"switchboard-core/auth"
"switchboard-core/store"
) )
// RequireAdmin returns middleware that restricts access to admin users. // RequireAdmin returns middleware that restricts access to users with the
// Must be used after Auth() middleware which sets "role" in context. // surface.admin.access permission (i.e. members of the Admins group).
func RequireAdmin() gin.HandlerFunc { // Must be used after Auth() middleware which sets "user_id" in context.
func RequireAdmin(stores store.Stores) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get("role") userID := c.GetString("user_id")
if !exists || role != "admin" { perms, err := resolveAndCachePerms(c, stores, userID)
if err != nil || !perms[auth.PermSurfaceAdminAccess] {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "admin access required", "error": "admin access required",
}) })

View File

@@ -20,19 +20,17 @@ import (
type Claims struct { type Claims struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// ─── User Status Cache ────────────────────────────────────── // ─── User Status Cache ──────────────────────────────────────
// Short-TTL cache so deactivation and role changes take effect // Short-TTL cache so deactivation takes effect within seconds,
// within seconds, without a DB query on every single request. // without a DB query on every single request.
const userCacheTTL = 30 * time.Second const userCacheTTL = 30 * time.Second
type userCacheEntry struct { type userCacheEntry struct {
isActive bool isActive bool
role string
fetchedAt time.Time fetchedAt time.Time
} }
@@ -64,9 +62,9 @@ func (c *UserStatusCache) get(userID string) (userCacheEntry, bool) {
return e, true return e, true
} }
func (c *UserStatusCache) set(userID string, active bool, role string) { func (c *UserStatusCache) set(userID string, active bool) {
c.mu.Lock() c.mu.Lock()
c.entries[userID] = userCacheEntry{isActive: active, role: role, fetchedAt: time.Now()} c.entries[userID] = userCacheEntry{isActive: active, fetchedAt: time.Now()}
c.mu.Unlock() c.mu.Unlock()
} }
@@ -90,44 +88,43 @@ func (c *UserStatusCache) evictExpired() {
// ─── Shared user verification ─────────────────────────────── // ─── Shared user verification ───────────────────────────────
// verifyUser checks is_active and resolves the current DB role. // verifyUser checks that the user exists and is active.
// Returns (role, nil) on success or ("", error-already-sent) on failure. // Returns true on success or false (with error already sent) on failure.
// Callers must abort the request on error. func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) bool {
func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) (string, bool) {
userID := claims.UserID userID := claims.UserID
if userID == "" { if userID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"})
return "", false return false
} }
return verifyUserByID(c, userID, users, cache) return verifyUserByID(c, userID, users, cache)
} }
// verifyUserByID checks is_active and resolves the current DB role for a user ID. // verifyUserByID checks is_active for a user ID.
// Used by both JWT auth (via verifyUser) and ticket auth. // Used by both JWT auth (via verifyUser) and ticket auth.
func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) (string, bool) { func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) bool {
// Cache hit // Cache hit
if entry, ok := cache.get(userID); ok { if entry, ok := cache.get(userID); ok {
if !entry.isActive { if !entry.isActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"})
return "", false return false
} }
return entry.role, true return true
} }
// Cache miss → DB lookup // Cache miss → DB lookup
user, err := users.GetByID(c.Request.Context(), userID) user, err := users.GetByID(c.Request.Context(), userID)
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return "", false return false
} }
cache.set(userID, user.IsActive, user.Role) cache.set(userID, user.IsActive)
if !user.IsActive { if !user.IsActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"})
return "", false return false
} }
return user.Role, true return true
} }
// parseAndValidateJWT parses a raw JWT string and returns claims if valid. // parseAndValidateJWT parses a raw JWT string and returns claims if valid.
@@ -188,15 +185,13 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin
return return
} }
// Verify user is active and resolve current role from DB // Verify user is active
role, ok := verifyUser(c, claims, users, cache) if !verifyUser(c, claims, users, cache) {
if !ok { return
return // verifyUser already sent the error response
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("email", claims.Email) c.Set("email", claims.Email)
c.Set("role", role) // from DB, not JWT claims
c.Next() c.Next()
} }
} }
@@ -304,14 +299,11 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return return
} }
// Ticket is valid — resolve role from DB (same as JWT path) if !verifyUserByID(c, userID, users, cache) {
role, ok := verifyUserByID(c, userID, users, cache)
if !ok {
return return
} }
c.Set("user_id", userID) c.Set("user_id", userID)
c.Set("role", role)
c.Set("ws_auth", "ticket") c.Set("ws_auth", "ticket")
c.Next() c.Next()
return return
@@ -329,14 +321,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return return
} }
role, ok := verifyUser(c, claims, users, cache) if !verifyUser(c, claims, users, cache) {
if !ok {
return return
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("email", claims.Email) c.Set("email", claims.Email)
c.Set("role", role)
c.Set("ws_auth", "token_legacy") c.Set("ws_auth", "token_legacy")
c.Next() c.Next()
return return
@@ -367,14 +357,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
return return
} }
role, ok := verifyUser(c, claims, users, cache) if !verifyUser(c, claims, users, cache) {
if !ok {
return return
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("email", claims.Email) c.Set("email", claims.Email)
c.Set("role", role)
c.Set("ws_auth", "bearer") c.Set("ws_auth", "bearer")
c.Next() c.Next()
} }

View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"switchboard-core/auth"
"switchboard-core/config" "switchboard-core/config"
"switchboard-core/database" "switchboard-core/database"
"switchboard-core/store" "switchboard-core/store"
@@ -60,37 +61,34 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
return return
} }
// Resolve user role — redirect (not JSON) on any failure. // Check user is active — redirect (not JSON) on failure.
var role string
if entry, hit := cache.get(claims.UserID); hit { if entry, hit := cache.get(claims.UserID); hit {
if !entry.isActive { if !entry.isActive {
redirectToLogin(c, loginPath) redirectToLogin(c, loginPath)
return return
} }
role = entry.role
} else { } else {
user, err := users.GetByID(c.Request.Context(), claims.UserID) user, err := users.GetByID(c.Request.Context(), claims.UserID)
if err != nil || !user.IsActive { if err != nil || !user.IsActive {
redirectToLogin(c, loginPath) redirectToLogin(c, loginPath)
return return
} }
cache.set(claims.UserID, user.IsActive, user.Role) cache.set(claims.UserID, user.IsActive)
role = user.Role
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("email", claims.Email) c.Set("email", claims.Email)
c.Set("role", role)
c.Next() c.Next()
} }
} }
// RequireAdminPage aborts with 403 if the user isn't an admin. // RequireAdminPage aborts with 403 if the user lacks surface.admin.access.
// Use after AuthOrRedirect for admin-only page routes. // Use after AuthOrRedirect for admin-only page routes.
func RequireAdminPage() gin.HandlerFunc { func RequireAdminPage(stores store.Stores) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, _ := c.Get("role") userID := c.GetString("user_id")
if role != "admin" { perms, err := resolveAndCachePerms(c, stores, userID)
if err != nil || !perms[auth.PermSurfaceAdminAccess] {
c.String(http.StatusForbidden, "Admin access required") c.String(http.StatusForbidden, "Admin access required")
c.Abort() c.Abort()
return return

View File

@@ -12,17 +12,11 @@ import (
const permCacheKey = "resolved_permissions" const permCacheKey = "resolved_permissions"
// RequirePermission returns middleware that enforces a named permission. // RequirePermission returns middleware that enforces a named permission.
// Admin users bypass the check entirely. Permission resolution is cached in // Permission resolution is cached in the request context — computed at most
// the request context — computed at most once per request regardless of how // once per request regardless of how many RequirePermission middlewares are
// many RequirePermission middlewares are chained. // chained. Admins receive permissions through the Admins group.
func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc { func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, _ := c.Get("role")
if role == "admin" {
c.Next()
return
}
userID := c.GetString("user_id") userID := c.GetString("user_id")
perms, err := resolveAndCachePerms(c, stores, userID) perms, err := resolveAndCachePerms(c, stores, userID)
if err != nil || !perms[perm] { if err != nil || !perms[perm] {

View File

@@ -23,12 +23,9 @@ const (
ScopePersonal = "personal" ScopePersonal = "personal"
) )
// ── Role Constants ────────────────────────── // ── Team Role Constants ─────────────────────
const ( const (
UserRoleUser = "user"
UserRoleAdmin = "admin"
TeamRoleAdmin = "admin" TeamRoleAdmin = "admin"
TeamRoleMember = "member" TeamRoleMember = "member"
) )
@@ -48,7 +45,6 @@ type User struct {
PasswordHash string `json:"-" db:"password_hash"` PasswordHash string `json:"-" db:"password_hash"`
DisplayName string `json:"display_name,omitempty" db:"display_name"` DisplayName string `json:"display_name,omitempty" db:"display_name"`
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"` AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
Role string `json:"role" db:"role"`
IsActive bool `json:"is_active" db:"is_active"` IsActive bool `json:"is_active" db:"is_active"`
Settings JSONMap `json:"settings,omitempty" db:"settings"` Settings JSONMap `json:"settings,omitempty" db:"settings"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
@@ -316,24 +312,15 @@ type Group struct {
TeamID *string `json:"team_id,omitempty" db:"team_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"`
CreatedBy *string `json:"created_by,omitempty" db:"created_by"` CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
Source string `json:"source" db:"source"` // manual (default), oidc, system Source string `json:"source" db:"source"` // manual (default), oidc, system
Permissions []string `json:"permissions" db:"permissions"` Permissions []string `json:"permissions" db:"permissions"`
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty" db:"token_budget_daily"` MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty" db:"token_budget_monthly"`
AllowedModels []string `json:"allowed_models,omitempty" db:"allowed_models"` // nil = unrestricted
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
} }
// GroupPatch holds optional fields for updating a group. // GroupPatch holds optional fields for updating a group.
type GroupPatch struct { type GroupPatch struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Permissions *[]string `json:"permissions,omitempty"` Permissions *[]string `json:"permissions,omitempty"`
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty"`
TokenBudgetMonthly *int64 `json:"token_budget_monthly,omitempty"`
AllowedModels *[]string `json:"allowed_models,omitempty"` // &[]string{} = restrict to none; nil = no change
ClearBudgetDaily bool `json:"clear_budget_daily,omitempty"`
ClearBudgetMonthly bool `json:"clear_budget_monthly,omitempty"`
ClearAllowedModels bool `json:"clear_allowed_models,omitempty"`
} }
// GroupMember links a user to a group. // GroupMember links a user to a group.

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log" "log"
"switchboard-core/auth"
"switchboard-core/events" "switchboard-core/events"
"switchboard-core/models" "switchboard-core/models"
"switchboard-core/store" "switchboard-core/store"
@@ -44,26 +45,23 @@ func RoleFallbackHandler(svc *Service, stores store.Stores) events.Handler {
body = fmt.Sprintf("Primary model error: %s", p.Error) body = fmt.Sprintf("Primary model error: %s", p.Error)
} }
// Notify all admin users // Notify all Admins group members
ctx := context.Background() ctx := context.Background()
admins, _, err := stores.Users.List(ctx, store.ListOptions{Limit: 500}) admins, err := stores.Groups.ListMembers(ctx, auth.AdminsGroupID)
if err != nil { if err != nil {
log.Printf("[notifications] failed to list users for role.fallback: %v", err) log.Printf("[notifications] failed to list admins for role.fallback: %v", err)
return return
} }
for _, u := range admins { for _, u := range admins {
if u.Role != "admin" {
continue
}
n := models.Notification{ n := models.Notification{
UserID: u.ID, UserID: u.UserID,
Type: models.NotifTypeRoleFallback, Type: models.NotifTypeRoleFallback,
Title: title, Title: title,
Body: body, Body: body,
} }
if err := svc.Notify(ctx, &n); err != nil { if err := svc.Notify(ctx, &n); err != nil {
log.Printf("[notifications] failed to create role.fallback notification for %s: %v", u.ID, err) log.Printf("[notifications] failed to create role.fallback notification for %s: %v", u.UserID, err)
} }
} }
} }

View File

@@ -93,9 +93,6 @@ type UserStore interface {
// ── CS2 additions (v0.29.0) ── // ── CS2 additions (v0.29.0) ──
// CountByRole returns the number of users with a given role.
CountByRole(ctx context.Context, role string) (int, error)
// CountAll returns the total number of users. // CountAll returns the total number of users.
CountAll(ctx context.Context) (int, error) CountAll(ctx context.Context) (int, error)

View File

@@ -43,17 +43,14 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
g.created_at, g.updated_at, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'), COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'::jsonb), COALESCE(g.permissions, '[]'::jsonb)
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models
FROM groups g WHERE g.id = $1`, id) FROM groups g WHERE g.id = $1`, id)
return scanGroup(row) return scanGroup(row)
} }
// Update applies a patch to a group. // Update applies a patch to a group.
// The Everyone group (source=system) allows permission/budget edits but not // System groups (source=system) allow permission edits but not name,
// name, description, or structural changes — those fields are silently ignored. // description, or structural changes — those fields are silently ignored.
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error { func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
var source string var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil { if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil {
@@ -76,25 +73,6 @@ func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPa
} }
b.Set("permissions", permsJSON) b.Set("permissions", permsJSON)
} }
if patch.ClearBudgetDaily {
b.SetNull("token_budget_daily")
} else if patch.TokenBudgetDaily != nil {
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
}
if patch.ClearBudgetMonthly {
b.SetNull("token_budget_monthly")
} else if patch.TokenBudgetMonthly != nil {
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
}
if patch.ClearAllowedModels {
b.SetNull("allowed_models")
} else if patch.AllowedModels != nil {
modelsJSON, err := json.Marshal(*patch.AllowedModels)
if err != nil {
return fmt.Errorf("marshal allowed_models: %w", err)
}
b.Set("allowed_models", modelsJSON)
}
if !b.HasSets() { if !b.HasSets() {
return nil return nil
} }
@@ -138,10 +116,7 @@ const groupSelectCols = `
g.created_at, g.updated_at, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'), COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'::jsonb), COALESCE(g.permissions, '[]'::jsonb)`
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models`
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) { func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `SELECT`+groupSelectCols+` return queryGroups(ctx, `SELECT`+groupSelectCols+`
@@ -250,28 +225,17 @@ func scanGroup(row groupScanner) (*models.Group, error) {
var teamID sql.NullString var teamID sql.NullString
var createdBy sql.NullString var createdBy sql.NullString
var permsJSON []byte var permsJSON []byte
var budgetDaily, budgetMonthly sql.NullInt64
var allowedJSON []byte
if err := row.Scan( if err := row.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON, &permsJSON,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
g.TeamID = NullableStringPtr(teamID) g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy) g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsJSON) g.Permissions = unmarshalStringSlice(permsJSON)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
g.AllowedModels = unmarshalStringSlice(allowedJSON)
}
return &g, nil return &g, nil
} }
@@ -288,28 +252,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
var teamID sql.NullString var teamID sql.NullString
var createdBy sql.NullString var createdBy sql.NullString
var permsJSON []byte var permsJSON []byte
var budgetDaily, budgetMonthly sql.NullInt64
var allowedJSON []byte
if err := rows.Scan( if err := rows.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON, &permsJSON,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
g.TeamID = NullableStringPtr(teamID) g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy) g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsJSON) g.Permissions = unmarshalStringSlice(permsJSON)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if len(allowedJSON) > 0 && string(allowedJSON) != "null" {
g.AllowedModels = unmarshalStringSlice(allowedJSON)
}
result = append(result, g) result = append(result, g)
} }
return result, rows.Err() return result, rows.Err()

View File

@@ -16,10 +16,10 @@ type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} } func NewUserStore() *UserStore { return &UserStore{} }
const userCols = `id, username, email, password_hash, display_name, avatar_url, const userCols = `id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at, is_active, settings, created_at, updated_at, last_login_at,
auth_source, external_id, handle` auth_source, external_id, handle`
const userListCols = `id, username, email, display_name, avatar_url, role, is_active, const userListCols = `id, username, email, display_name, avatar_url, is_active,
settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` settings, created_at, updated_at, last_login_at, auth_source, external_id, handle`
func (s *UserStore) Create(ctx context.Context, u *models.User) error { func (s *UserStore) Create(ctx context.Context, u *models.User) error {
@@ -27,10 +27,10 @@ func (s *UserStore) Create(ctx context.Context, u *models.User) error {
u.AuthSource = "builtin" u.AuthSource = "builtin"
} }
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings, auth_source, external_id, handle) INSERT INTO users (username, email, password_hash, display_name, is_active, settings, auth_source, external_id, handle)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at`, RETURNING id, created_at, updated_at`,
u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive,
ToJSON(u.Settings), u.AuthSource, u.ExternalID, u.Handle, ToJSON(u.Settings), u.AuthSource, u.ExternalID, u.Handle,
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt) ).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
} }
@@ -100,7 +100,7 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.
var extID, hdl sql.NullString var extID, hdl sql.NullString
var sj []byte var sj []byte
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av,
&u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
&u.AuthSource, &extID, &hdl); err != nil { &u.AuthSource, &extID, &hdl); err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -173,7 +173,7 @@ func scanOneUser(ctx context.Context, query string, args ...interface{}) (*model
var sj []byte var sj []byte
err := DB.QueryRowContext(ctx, query, args...).Scan( err := DB.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Username, &u.Email, &ph, &dn, &av, &u.ID, &u.Username, &u.Email, &ph, &dn, &av,
&u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
&u.AuthSource, &extID, &hdl, &u.AuthSource, &extID, &hdl,
) )
if err != nil { if err != nil {
@@ -233,13 +233,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── // ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE role = $1`, role).Scan(&count)
return count, err
}
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE users SET settings = ( UPDATE users SET settings = (

View File

@@ -48,22 +48,17 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
var teamID sql.NullString var teamID sql.NullString
var createdBy sql.NullString var createdBy sql.NullString
var permsStr sql.NullString var permsStr sql.NullString
var budgetDaily, budgetMonthly sql.NullInt64
var allowedStr sql.NullString
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
g.created_at, g.updated_at, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'), COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'), COALESCE(g.permissions, '[]')
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models
FROM groups g WHERE g.id = ?`, id).Scan( FROM groups g WHERE g.id = ?`, id).Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr, &permsStr,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -71,20 +66,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
g.TeamID = NullableStringPtr(teamID) g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy) g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsStr.String) g.Permissions = unmarshalStringSlice(permsStr.String)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if allowedStr.Valid && allowedStr.String != "" {
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
}
return &g, nil return &g, nil
} }
// Update applies a patch to a group. // Update applies a patch to a group.
// system groups allow permission/budget edits but not structural field changes. // System groups allow permission edits but not structural field changes.
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error { func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
var source string var source string
if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil { if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = ?", id).Scan(&source); err != nil {
@@ -107,25 +93,6 @@ func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPa
} }
b.Set("permissions", string(permsJSON)) b.Set("permissions", string(permsJSON))
} }
if patch.ClearBudgetDaily {
b.SetNull("token_budget_daily")
} else if patch.TokenBudgetDaily != nil {
b.Set("token_budget_daily", *patch.TokenBudgetDaily)
}
if patch.ClearBudgetMonthly {
b.SetNull("token_budget_monthly")
} else if patch.TokenBudgetMonthly != nil {
b.Set("token_budget_monthly", *patch.TokenBudgetMonthly)
}
if patch.ClearAllowedModels {
b.SetNull("allowed_models")
} else if patch.AllowedModels != nil {
modelsJSON, err := json.Marshal(*patch.AllowedModels)
if err != nil {
return fmt.Errorf("marshal allowed_models: %w", err)
}
b.Set("allowed_models", string(modelsJSON))
}
if !b.HasSets() { if !b.HasSets() {
return nil return nil
} }
@@ -169,10 +136,7 @@ const groupSelectCols = `
g.created_at, g.updated_at, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
COALESCE(g.source, 'manual'), COALESCE(g.source, 'manual'),
COALESCE(g.permissions, '[]'), COALESCE(g.permissions, '[]')`
g.token_budget_daily,
g.token_budget_monthly,
g.allowed_models`
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) { func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
return queryGroups(ctx, `SELECT `+groupSelectCols+` return queryGroups(ctx, `SELECT `+groupSelectCols+`
@@ -285,28 +249,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
var teamID sql.NullString var teamID sql.NullString
var createdBy sql.NullString var createdBy sql.NullString
var permsStr sql.NullString var permsStr sql.NullString
var budgetDaily, budgetMonthly sql.NullInt64
var allowedStr sql.NullString
if err := rows.Scan( if err := rows.Scan(
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr, &permsStr,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
g.TeamID = NullableStringPtr(teamID) g.TeamID = NullableStringPtr(teamID)
g.CreatedBy = NullableStringPtr(createdBy) g.CreatedBy = NullableStringPtr(createdBy)
g.Permissions = unmarshalStringSlice(permsStr.String) g.Permissions = unmarshalStringSlice(permsStr.String)
if budgetDaily.Valid {
g.TokenBudgetDaily = &budgetDaily.Int64
}
if budgetMonthly.Valid {
g.TokenBudgetMonthly = &budgetMonthly.Int64
}
if allowedStr.Valid && allowedStr.String != "" {
g.AllowedModels = unmarshalStringSlice(allowedStr.String)
}
result = append(result, g) result = append(result, g)
} }
return result, rows.Err() return result, rows.Err()

View File

@@ -16,10 +16,10 @@ type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} } func NewUserStore() *UserStore { return &UserStore{} }
const userCols = `id, username, email, password_hash, display_name, avatar_url, const userCols = `id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at, is_active, settings, created_at, updated_at, last_login_at,
auth_source, external_id, handle` auth_source, external_id, handle`
const userListCols = `id, username, email, display_name, avatar_url, role, is_active, const userListCols = `id, username, email, display_name, avatar_url, is_active,
settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` settings, created_at, updated_at, last_login_at, auth_source, external_id, handle`
func (s *UserStore) Create(ctx context.Context, u *models.User) error { func (s *UserStore) Create(ctx context.Context, u *models.User) error {
@@ -31,10 +31,10 @@ func (s *UserStore) Create(ctx context.Context, u *models.User) error {
u.AuthSource = "builtin" u.AuthSource = "builtin"
} }
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO users (id, username, email, password_hash, display_name, role, is_active, INSERT INTO users (id, username, email, password_hash, display_name, is_active,
settings, created_at, updated_at, auth_source, external_id, handle) settings, created_at, updated_at, auth_source, external_id, handle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive,
ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt), ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt),
u.AuthSource, u.ExternalID, u.Handle, u.AuthSource, u.ExternalID, u.Handle,
) )
@@ -106,7 +106,7 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.
var extID, hdl sql.NullString var extID, hdl sql.NullString
var sj []byte var sj []byte
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av,
&u.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
&u.AuthSource, &extID, &hdl); err != nil { &u.AuthSource, &extID, &hdl); err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -180,7 +180,7 @@ func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface
var sj []byte var sj []byte
err := DB.QueryRowContext(ctx, query, args...).Scan( err := DB.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Username, &u.Email, &ph, &dn, &av, &u.ID, &u.Username, &u.Email, &ph, &dn, &av,
&u.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt),
&u.AuthSource, &extID, &hdl, &u.AuthSource, &extID, &hdl,
) )
if err != nil { if err != nil {
@@ -240,13 +240,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── // ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE role = ?`, role).Scan(&count)
return count, err
}
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE users SET settings = json_patch( UPDATE users SET settings = json_patch(

View File

@@ -166,7 +166,7 @@ export function createDomains(restClient) {
create: (data) => rc.post('/api/v1/admin/users', data), create: (data) => rc.post('/api/v1/admin/users', data),
del: (id) => rc.del(`/api/v1/admin/users/${id}`), del: (id) => rc.del(`/api/v1/admin/users/${id}`),
resetPassword: (id, pw) => rc.post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }), resetPassword: (id, pw) => rc.post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }),
updateRole: (id, role) => rc.put(`/api/v1/admin/users/${id}/role`, { role }), updateRole: (id, isAdmin) => rc.put(`/api/v1/admin/users/${id}/role`, { is_admin: isAdmin }),
toggleActive: (id, active, opts) => rc.put(`/api/v1/admin/users/${id}/active`, { is_active: active, ...opts }), toggleActive: (id, active, opts) => rc.put(`/api/v1/admin/users/${id}/active`, { is_active: active, ...opts }),
permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`), permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`),
resetVault: (id) => rc.post(`/api/v1/admin/users/${id}/vault/reset`, {}), resetVault: (id) => rc.post(`/api/v1/admin/users/${id}/vault/reset`, {}),

View File

@@ -11,7 +11,6 @@ export default function GroupsSection() {
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [allPerms, setAllPerms] = useState([]); const [allPerms, setAllPerms] = useState([]);
const [allUsers, setAllUsers] = useState([]); const [allUsers, setAllUsers] = useState([]);
const [allModels, setAllModels] = useState([]);
const [groupData, setGroupData] = useState({}); const [groupData, setGroupData] = useState({});
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [showAddMember, setShowAddMember] = useState(false); const [showAddMember, setShowAddMember] = useState(false);
@@ -30,21 +29,16 @@ export default function GroupsSection() {
setDetail(group); setDetail(group);
setGroupData({ setGroupData({
permissions: group.permissions || [], permissions: group.permissions || [],
token_budget_daily: group.token_budget_daily ?? '',
token_budget_monthly: group.token_budget_monthly ?? '',
allowed_models: group.allowed_models || [],
}); });
try { try {
const [m, p, u, models] = await Promise.all([ const [m, p, u] = await Promise.all([
sw.api.admin.groups.members(group.id), sw.api.admin.groups.members(group.id),
sw.api.admin.permissions.list(), sw.api.admin.permissions.list(),
sw.api.admin.users.list(), sw.api.admin.users.list(),
sw.api.admin.models.list().catch(() => []),
]); ]);
setMembers(m || []); setMembers(m || []);
setAllPerms(p.permissions || []); setAllPerms(p.permissions || []);
setAllUsers(u || []); setAllUsers(u || []);
setAllModels(models || []);
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
} }
@@ -63,13 +57,10 @@ export default function GroupsSection() {
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
} }
async function savePermsAndBudgets() { async function savePermissions() {
try { try {
await sw.api.admin.groups.update(detail.id, { await sw.api.admin.groups.update(detail.id, {
permissions: groupData.permissions, permissions: groupData.permissions,
token_budget_daily: groupData.token_budget_daily || null,
token_budget_monthly: groupData.token_budget_monthly || null,
allowed_models: groupData.allowed_models.length ? groupData.allowed_models : null,
}); });
sw.toast('Saved', 'success'); sw.toast('Saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
@@ -84,15 +75,6 @@ export default function GroupsSection() {
}); });
} }
function toggleModel(modelId) {
setGroupData(prev => {
const list = prev.allowed_models.includes(modelId)
? prev.allowed_models.filter(m => m !== modelId)
: [...prev.allowed_models, modelId];
return { ...prev, allowed_models: list };
});
}
async function addMember(e) { async function addMember(e) {
e.preventDefault(); e.preventDefault();
const uid = e.target.userId.value; const uid = e.target.userId.value;
@@ -151,37 +133,8 @@ export default function GroupsSection() {
</div> </div>
</div> </div>
<div class="settings-section" style="margin-bottom:16px;">
<h5 style="margin:0 0 8px;">Token Budgets</h5>
<div class="form-row" style="gap:12px;">
<div class="form-group" style="flex:1;"><label>Daily limit</label>
<input type="number" placeholder="Unlimited" min="0" value=${groupData.token_budget_daily}
onInput=${e => setGroupData(p => ({ ...p, token_budget_daily: e.target.value ? Number(e.target.value) : '' }))} />
</div>
<div class="form-group" style="flex:1;"><label>Monthly limit</label>
<input type="number" placeholder="Unlimited" min="0" value=${groupData.token_budget_monthly}
onInput=${e => setGroupData(p => ({ ...p, token_budget_monthly: e.target.value ? Number(e.target.value) : '' }))} />
</div>
</div>
</div>
<div class="settings-section" style="margin-bottom:16px;">
<h5 style="margin:0 0 8px;">Allowed Models</h5>
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">Leave empty for unrestricted.</p>
<div class="admin-models-grid">
${allModels.map(m => html`
<label class="toggle-label" key=${m.id || m.model_id}>
<input type="checkbox" checked=${groupData.allowed_models.includes(m.model_id || m.id)}
onChange=${() => toggleModel(m.model_id || m.id)} />
<span class="toggle-track"></span>
<span>${m.display_name || m.model_id || m.id}</span>
</label>
`)}
</div>
</div>
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<button class="btn-small btn-primary" onClick=${savePermsAndBudgets}>Save Permissions & Budgets</button> <button class="btn-small btn-primary" onClick=${savePermissions}>Save Permissions</button>
</div> </div>
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" /> <hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" />

View File

@@ -33,7 +33,7 @@ export default function UsersSection() {
username: form.username.value.trim(), username: form.username.value.trim(),
email: form.email.value.trim(), email: form.email.value.trim(),
password: form.password.value, password: form.password.value,
role: form.role.value, is_admin: form.is_admin.checked,
}; };
if (!data.username || !data.password) return sw.toast('Username and password required', 'error'); if (!data.username || !data.password) return sw.toast('Username and password required', 'error');
try { try {
@@ -52,10 +52,10 @@ export default function UsersSection() {
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
} }
async function changeRole(id, role) { async function toggleAdmin(id, isAdmin) {
try { try {
await sw.api.admin.users.updateRole(id, role); await sw.api.admin.users.updateRole(id, isAdmin);
sw.toast(`Role updated to ${role}`, 'success'); sw.toast(isAdmin ? 'Admin granted' : 'Admin revoked', 'success');
load(); load();
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
} }
@@ -112,8 +112,8 @@ export default function UsersSection() {
<div class="form-group"><label>Username</label><input name="username" placeholder="username" autocomplete="off" /></div> <div class="form-group"><label>Username</label><input name="username" placeholder="username" autocomplete="off" /></div>
<div class="form-group"><label>Email</label><input name="email" placeholder="email@example.com" autocomplete="off" /></div> <div class="form-group"><label>Email</label><input name="email" placeholder="email@example.com" autocomplete="off" /></div>
<div class="form-group"><label>Password</label><input name="password" type="password" placeholder="min 8 chars" autocomplete="new-password" /></div> <div class="form-group"><label>Password</label><input name="password" type="password" placeholder="min 8 chars" autocomplete="new-password" /></div>
<div class="form-group"><label>Role</label> <div class="form-group" style="align-self:flex-end;">
<select name="role"><option value="user">User</option><option value="admin">Admin</option></select> <label class="toggle-label"><input type="checkbox" name="is_admin" /><span class="toggle-track"></span><span>Admin</span></label>
</div> </div>
</div> </div>
<div class="form-row" style="margin-top:8px;gap:8px;"> <div class="form-row" style="margin-top:8px;gap:8px;">
@@ -131,17 +131,11 @@ export default function UsersSection() {
<strong>${u.username}</strong> <strong>${u.username}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span> <span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span>
${' '}${statusBadge(u)} ${' '}${statusBadge(u)}
${' '}<span class="badge ${u.role === 'admin' ? 'badge-admin' : ''}">${u.role}</span>
</div> </div>
<div class="admin-actions-cell"> <div class="admin-actions-cell">
${u.status === 'pending' && html` ${u.status === 'pending' && html`
<button class="btn-small btn-primary" onClick=${() => approveUser(u.id)}>Approve</button> <button class="btn-small btn-primary" onClick=${() => approveUser(u.id)}>Approve</button>
`} `}
<select value=${u.role} onChange=${e => changeRole(u.id, e.target.value)}
style="font-size:11px;padding:2px 4px;">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
${u.is_active ${u.is_active
? html`<button class="btn-small" onClick=${() => toggleActive(u.id, false)}>Disable</button>` ? html`<button class="btn-small" onClick=${() => toggleActive(u.id, false)}>Disable</button>`
: html`<button class="btn-small" onClick=${() => toggleActive(u.id, true)}>Enable</button>` : html`<button class="btn-small" onClick=${() => toggleActive(u.id, true)}>Enable</button>`