Feat admin rbac migration (#1)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
57
CHANGELOG.md
57
CHANGELOG.md
@@ -2,7 +2,62 @@
|
||||
|
||||
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.
|
||||
|
||||
|
||||
14
ROADMAP.md
14
ROADMAP.md
@@ -36,11 +36,17 @@ platform that extensions build on.
|
||||
The contract that extensions build against. Three trigger primitives,
|
||||
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
|
||||
- **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
|
||||
Starlark extension using triggers + ext_data + notifications
|
||||
- **Settings override model**: RBAC controls scope auth (admin → global,
|
||||
|
||||
@@ -82,7 +82,6 @@ func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result
|
||||
Username: strings.ToLower(req.Username),
|
||||
Email: strings.ToLower(req.Email),
|
||||
PasswordHash: string(hash),
|
||||
Role: models.UserRoleUser,
|
||||
IsActive: defaultActive,
|
||||
AuthSource: string(ModeBuiltin),
|
||||
Handle: handle,
|
||||
@@ -92,6 +91,8 @@ func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result
|
||||
return nil, err
|
||||
}
|
||||
|
||||
EnsureEveryoneGroup(ctx, stores, user.ID)
|
||||
|
||||
return &Result{User: user, IsNewUser: true, VaultHint: req.Password}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ type MTLSConfig struct {
|
||||
HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint")
|
||||
AutoActivate bool // auto-activate new users (default true)
|
||||
DefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
DefaultRole string // "user" (default) or "admin"
|
||||
}
|
||||
|
||||
// MTLSProvider authenticates via client certificate headers injected
|
||||
@@ -48,9 +47,6 @@ func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider {
|
||||
if cfg.HeaderFingerprint == "" {
|
||||
cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint"
|
||||
}
|
||||
if cfg.DefaultRole == "" {
|
||||
cfg.DefaultRole = models.UserRoleUser
|
||||
}
|
||||
return &MTLSProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
@@ -134,7 +130,6 @@ func (p *MTLSProvider) autoProvision(
|
||||
Username: handle,
|
||||
Email: email,
|
||||
DisplayName: cn,
|
||||
Role: p.cfg.DefaultRole,
|
||||
IsActive: true,
|
||||
AuthSource: string(ModeMTLS),
|
||||
ExternalID: &fingerprint,
|
||||
@@ -146,6 +141,7 @@ func (p *MTLSProvider) autoProvision(
|
||||
}
|
||||
|
||||
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn)
|
||||
EnsureEveryoneGroup(ctx, stores, user.ID)
|
||||
|
||||
// Auto-add to default team if configured
|
||||
if p.cfg.DefaultTeam != "" {
|
||||
|
||||
@@ -96,24 +96,17 @@ func TestMTLSConfig_Defaults(t *testing.T) {
|
||||
if p.cfg.HeaderFingerprint != "X-SSL-Client-Fingerprint" {
|
||||
t.Errorf("HeaderFingerprint = %q, want X-SSL-Client-Fingerprint", p.cfg.HeaderFingerprint)
|
||||
}
|
||||
if p.cfg.DefaultRole != "user" {
|
||||
t.Errorf("DefaultRole = %q, want user", p.cfg.DefaultRole)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSConfig_Custom(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{
|
||||
HeaderDN: "X-Client-Cert-DN",
|
||||
HeaderVerify: "X-Client-Cert-Verify",
|
||||
DefaultRole: "admin",
|
||||
})
|
||||
|
||||
if p.cfg.HeaderDN != "X-Client-Cert-DN" {
|
||||
t.Errorf("HeaderDN = %q, want X-Client-Cert-DN", p.cfg.HeaderDN)
|
||||
}
|
||||
if p.cfg.DefaultRole != "admin" {
|
||||
t.Errorf("DefaultRole = %q, want admin", p.cfg.DefaultRole)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSProvider_Mode(t *testing.T) {
|
||||
|
||||
@@ -31,7 +31,6 @@ type OIDCConfig struct {
|
||||
RedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback"
|
||||
AutoActivate bool // auto-activate new users (default true)
|
||||
DefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
DefaultRole string // "user" (default) or "admin"
|
||||
RolesClaim string // JWT claim for role mapping (default "realm_access.roles")
|
||||
GroupsClaim string // JWT claim for group sync (default "groups")
|
||||
AdminRole string // IdP role value that maps to admin (default "admin")
|
||||
@@ -92,9 +91,6 @@ func NewOIDCProvider(cfg OIDCConfig) (*OIDCProvider, error) {
|
||||
if cfg.ClientID == "" {
|
||||
return nil, fmt.Errorf("OIDC_CLIENT_ID is required")
|
||||
}
|
||||
if cfg.DefaultRole == "" {
|
||||
cfg.DefaultRole = models.UserRoleUser
|
||||
}
|
||||
if cfg.RolesClaim == "" {
|
||||
cfg.RolesClaim = "realm_access.roles"
|
||||
}
|
||||
@@ -164,11 +160,9 @@ func (p *OIDCProvider) Authenticate(c *gin.Context, stores store.Stores) (*Resul
|
||||
return nil, ErrInactive
|
||||
}
|
||||
|
||||
// Re-evaluate role on every login
|
||||
role := p.resolveRole(claims)
|
||||
if role != user.Role {
|
||||
stores.Users.Update(ctx, user.ID, map[string]interface{}{"role": role})
|
||||
user.Role = role
|
||||
// Sync Admins group based on IdP role claim
|
||||
if p.isIdPAdmin(claims) {
|
||||
AddToAdminsGroup(ctx, stores, user.ID)
|
||||
}
|
||||
|
||||
// Sync groups
|
||||
@@ -310,14 +304,14 @@ func (p *OIDCProvider) validateToken(tokenString string) (*oidcClaims, error) {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) resolveRole(claims *oidcClaims) string {
|
||||
// Check realm_access.roles for admin role
|
||||
// isIdPAdmin checks whether the OIDC claims include the configured admin role.
|
||||
func (p *OIDCProvider) isIdPAdmin(claims *oidcClaims) bool {
|
||||
for _, role := range claims.RealmAccess.Roles {
|
||||
if role == p.cfg.AdminRole {
|
||||
return models.UserRoleAdmin
|
||||
return true
|
||||
}
|
||||
}
|
||||
return p.cfg.DefaultRole
|
||||
return false
|
||||
}
|
||||
|
||||
// syncGroups compares IdP groups claim with internal group memberships
|
||||
@@ -454,14 +448,12 @@ func (p *OIDCProvider) autoProvision(
|
||||
displayName = username
|
||||
}
|
||||
|
||||
role := p.resolveRole(claims)
|
||||
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username))
|
||||
|
||||
user := &models.User{
|
||||
Username: strings.ToLower(username),
|
||||
Email: strings.ToLower(email),
|
||||
DisplayName: displayName,
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
AuthSource: string(ModeOIDC),
|
||||
ExternalID: &sub,
|
||||
@@ -474,6 +466,11 @@ func (p *OIDCProvider) autoProvision(
|
||||
|
||||
log.Printf("[auth/oidc] auto-provisioned user %s (sub=%s, email=%s)", user.ID, sub, email)
|
||||
|
||||
EnsureEveryoneGroup(ctx, stores, user.ID)
|
||||
if p.isIdPAdmin(claims) {
|
||||
AddToAdminsGroup(ctx, stores, user.ID)
|
||||
}
|
||||
|
||||
// Auto-add to default team
|
||||
if p.cfg.DefaultTeam != "" {
|
||||
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {
|
||||
|
||||
@@ -2,18 +2,23 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in
|
||||
// migration 020. Every authenticated user receives its permissions without an
|
||||
// migration 002. Every authenticated user receives its permissions without an
|
||||
// explicit membership row.
|
||||
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// AdminsGroupID is the stable ID of the "Admins" system group seeded in
|
||||
// migration 002. Members receive surface.admin.access and all platform
|
||||
// permissions. Replaces the legacy users.role = 'admin' check.
|
||||
const AdminsGroupID = "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
// Permission constants — domain.action convention.
|
||||
const (
|
||||
PermSurfaceAdminAccess = "surface.admin.access" // full admin panel access (replaces role check)
|
||||
PermExtensionUse = "extension.use" // use installed extensions
|
||||
PermExtensionInstall = "extension.install" // install/manage extension packages
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions
|
||||
@@ -25,6 +30,7 @@ const (
|
||||
// AllPermissions is the complete set of valid permission strings.
|
||||
// Used for validation in handlers and rendering checkboxes in admin UI.
|
||||
var AllPermissions = []string{
|
||||
PermSurfaceAdminAccess,
|
||||
PermExtensionUse,
|
||||
PermExtensionInstall,
|
||||
PermWorkflowCreate,
|
||||
@@ -36,19 +42,10 @@ var AllPermissions = []string{
|
||||
// ── Resolution ──────────────────────────────
|
||||
|
||||
// ResolvePermissions returns the effective permission set for a user.
|
||||
// Always includes the Everyone group regardless of membership.
|
||||
// Callers should short-circuit for role=admin before calling this.
|
||||
// Unions permissions from all groups the user is a member of (including Everyone).
|
||||
func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
perms := make(map[string]bool)
|
||||
|
||||
// Always apply Everyone group — no membership row required.
|
||||
if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil {
|
||||
for _, p := range everyone.Permissions {
|
||||
perms[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Union all explicit group memberships.
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return perms, err
|
||||
@@ -62,91 +59,19 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string)
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// ── Token Budget ────────────────────────────
|
||||
|
||||
// TokenBudget holds the resolved ceiling for a user and the time window it covers.
|
||||
type TokenBudget struct {
|
||||
Limit int64
|
||||
Period string // "daily" or "monthly"
|
||||
// EnsureEveryoneGroup adds a user to the Everyone group (idempotent).
|
||||
// Called on every user creation path so that Everyone membership is explicit.
|
||||
func EnsureEveryoneGroup(ctx context.Context, stores store.Stores, userID string) {
|
||||
_ = stores.Groups.AddMember(ctx, EveryoneGroupID, userID, userID)
|
||||
}
|
||||
|
||||
// ResolveTokenBudget returns the most restrictive token budget across all of the
|
||||
// user's groups. Returns nil if no group has a budget set (unrestricted).
|
||||
// Callers should skip budget enforcement when providerScope == "personal" (BYOK).
|
||||
func ResolveTokenBudget(ctx context.Context, stores store.Stores, userID string) (*TokenBudget, error) {
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// AddToAdminsGroup adds a user to the Admins group (idempotent).
|
||||
func AddToAdminsGroup(ctx context.Context, stores store.Stores, userID string) {
|
||||
_ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// RemoveFromAdminsGroup removes a user from the Admins group.
|
||||
func RemoveFromAdminsGroup(ctx context.Context, stores store.Stores, userID string) {
|
||||
_ = stores.Groups.RemoveMember(ctx, AdminsGroupID, userID)
|
||||
}
|
||||
|
||||
// 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 ─────────────────────────
|
||||
|
||||
// ResolveModelAllowlist returns the union of allowed model IDs across all of the
|
||||
// user's groups. Returns nil if the user is unrestricted (any group has a nil
|
||||
// allowlist, which means "no restriction").
|
||||
func ResolveModelAllowlist(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If any group has nil AllowedModels, the user is unrestricted.
|
||||
for _, g := range groups {
|
||||
if g.AllowedModels == nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// All groups have explicit allowlists — union them.
|
||||
allowed := make(map[string]bool)
|
||||
for _, g := range groups {
|
||||
for _, m := range g.AllowedModels {
|
||||
allowed[m] = true
|
||||
}
|
||||
}
|
||||
// No groups at all → fall through to Everyone group check.
|
||||
if len(groups) == 0 {
|
||||
if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil {
|
||||
if everyone.AllowedModels == nil {
|
||||
return nil, nil // Everyone has no restriction
|
||||
}
|
||||
for _, m := range everyone.AllowedModels {
|
||||
allowed[m] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowed) == 0 {
|
||||
return nil, nil // empty allowlists across all groups = unrestricted
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ type Config struct {
|
||||
MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint"
|
||||
MTLSAutoActivate bool // auto-activate new users (default true)
|
||||
MTLSDefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
MTLSDefaultRole string // "user" (default) or "admin"
|
||||
|
||||
// OIDC (v0.24.1)
|
||||
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"
|
||||
OIDCAutoActivate bool // auto-activate new users (default true)
|
||||
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")
|
||||
OIDCGroupsClaim string // JWT claim for group sync (default "groups")
|
||||
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"),
|
||||
MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true),
|
||||
MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""),
|
||||
MTLSDefaultRole: getEnv("MTLS_DEFAULT_ROLE", "user"),
|
||||
|
||||
// OIDC
|
||||
OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""),
|
||||
@@ -128,7 +125,6 @@ func Load() *Config {
|
||||
OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""),
|
||||
OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true),
|
||||
OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""),
|
||||
OIDCDefaultRole: getEnv("OIDC_DEFAULT_ROLE", "user"),
|
||||
OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"),
|
||||
OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"),
|
||||
OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"),
|
||||
|
||||
@@ -23,8 +23,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
password_hash TEXT,
|
||||
display_name VARCHAR(100),
|
||||
avatar_url TEXT,
|
||||
role VARCHAR(20) DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin'
|
||||
|
||||
@@ -44,9 +44,6 @@ CREATE TABLE IF NOT EXISTS groups (
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual'
|
||||
CHECK (source IN ('manual', 'oidc', 'system')),
|
||||
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
token_budget_daily BIGINT,
|
||||
token_budget_monthly BIGINT,
|
||||
allowed_models JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT groups_scope_team CHECK (
|
||||
@@ -83,3 +80,16 @@ VALUES (
|
||||
'["extension.use","workflow.submit"]'::jsonb
|
||||
)
|
||||
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;
|
||||
|
||||
@@ -12,7 +12,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
password_hash TEXT,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||
is_active INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
auth_source TEXT NOT NULL DEFAULT 'builtin',
|
||||
|
||||
@@ -40,9 +40,6 @@ CREATE TABLE IF NOT EXISTS groups (
|
||||
created_by TEXT REFERENCES users(id),
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc', 'system')),
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
token_budget_daily INTEGER,
|
||||
token_budget_monthly INTEGER,
|
||||
allowed_models TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -74,3 +71,15 @@ VALUES (
|
||||
'global', NULL, 'system',
|
||||
'["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"]'
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
if IsSQLite() {
|
||||
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 {
|
||||
t.Helper()
|
||||
handle := strings.ToLower(strings.ReplaceAll(username, " ", "-"))
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`
|
||||
INSERT INTO users (id, username, email, password_hash, role, auth_source, handle)
|
||||
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', ?)
|
||||
INSERT INTO users (id, username, email, password_hash, auth_source, handle)
|
||||
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', ?)
|
||||
`, id, username, email, handle)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestUser: %v", err)
|
||||
}
|
||||
SeedEveryoneGroupMember(t, id)
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`
|
||||
INSERT INTO users (username, email, password_hash, role, auth_source, handle)
|
||||
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', $3)
|
||||
INSERT INTO users (username, email, password_hash, auth_source, handle)
|
||||
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'builtin', $3)
|
||||
RETURNING id
|
||||
`, username, email, handle).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestUser: %v", err)
|
||||
}
|
||||
SeedEveryoneGroupMember(t, 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.
|
||||
func SeedTestChannel(t *testing.T, userID, title string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -69,29 +69,25 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Role string `json:"role"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
||||
role := models.UserRoleUser
|
||||
if req.Role == models.UserRoleAdmin {
|
||||
role = models.UserRoleAdmin
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: strings.ToLower(req.Username),
|
||||
Email: strings.ToLower(req.Email),
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
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) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username or email already exists"})
|
||||
return
|
||||
@@ -100,46 +96,55 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
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)
|
||||
c.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
var req struct {
|
||||
Role string `json:"role" binding:"required"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Guard: prevent demoting the last admin
|
||||
if req.Role != models.UserRoleAdmin {
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), id)
|
||||
if err != nil || user == nil {
|
||||
if _, err := h.stores.Users.GetByID(ctx, id); err != 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"})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role})
|
||||
h.auditLog(c, "user.role_change", "user", id, gin.H{"is_admin": req.IsAdmin})
|
||||
if h.onUserChanged != nil {
|
||||
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) {
|
||||
@@ -248,18 +253,22 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// Guard: prevent deleting the last admin
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), id)
|
||||
if err != nil || user == nil {
|
||||
if _, err := h.stores.Users.GetByID(c.Request.Context(), id); err != 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 {
|
||||
members, _ := h.stores.Groups.ListMembers(c.Request.Context(), auth.AdminsGroupID)
|
||||
isAdmin := false
|
||||
for _, m := range members {
|
||||
if m.UserID == id {
|
||||
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
|
||||
h.destroyVault(c, id)
|
||||
|
||||
@@ -32,7 +32,6 @@ const bcryptCost = 12
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -295,7 +294,6 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
||||
accessClaims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
@@ -327,7 +325,6 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"display_name": user.DisplayName,
|
||||
"role": user.Role,
|
||||
"handle": user.Handle,
|
||||
"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)
|
||||
if existing != nil {
|
||||
// Update password and ensure admin role
|
||||
// Update password
|
||||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||||
"password_hash": string(hash),
|
||||
"role": models.UserRoleAdmin,
|
||||
"is_active": true,
|
||||
})
|
||||
// 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]
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -521,7 +519,6 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC
|
||||
Username: strings.ToLower(cfg.AdminUsername),
|
||||
Email: strings.ToLower(email),
|
||||
PasswordHash: string(hash),
|
||||
Role: models.UserRoleAdmin,
|
||||
IsActive: true,
|
||||
AuthSource: "builtin",
|
||||
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)
|
||||
return
|
||||
}
|
||||
auth.EnsureEveryoneGroup(ctx, s, user.ID)
|
||||
auth.AddToAdminsGroup(ctx, s, user.ID)
|
||||
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
|
||||
}
|
||||
|
||||
// SeedUsers creates or updates users from the SEED_USERS env var.
|
||||
// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user".
|
||||
// Upsert: existing users get their password and role refreshed on every restart.
|
||||
// Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group.
|
||||
// Upsert: existing users get their password refreshed on every restart.
|
||||
// Gated to non-production environments.
|
||||
func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
|
||||
if cfg.SeedUsers == "" {
|
||||
@@ -561,16 +560,13 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
|
||||
|
||||
parts := strings.SplitN(entry, ":", 3)
|
||||
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
|
||||
}
|
||||
|
||||
username := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
password := strings.TrimSpace(parts[1])
|
||||
role := models.UserRoleUser
|
||||
if len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" {
|
||||
role = models.UserRoleAdmin
|
||||
}
|
||||
isAdmin := len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin"
|
||||
|
||||
if username == "" || 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
if existing != nil {
|
||||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||||
"password_hash": string(hash),
|
||||
"role": role,
|
||||
"is_active": true,
|
||||
})
|
||||
// Probe vault with current password — only destroys if seal is stale
|
||||
if existing.Handle == "" {
|
||||
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
|
||||
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]
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -610,7 +608,6 @@ func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache)
|
||||
Username: username,
|
||||
Email: username + "@switchboard.local",
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
AuthSource: "builtin",
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ func TestJWTGeneration(t *testing.T) {
|
||||
claims := Claims{
|
||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
Email: "test@example.com",
|
||||
Role: "user",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
|
||||
@@ -75,9 +74,6 @@ func TestJWTGeneration(t *testing.T) {
|
||||
if 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) {
|
||||
@@ -86,7 +82,6 @@ func TestJWTExpired(t *testing.T) {
|
||||
claims := Claims{
|
||||
UserID: "test-user",
|
||||
Email: "test@example.com",
|
||||
Role: "user",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)),
|
||||
@@ -109,7 +104,6 @@ func TestJWTWrongSecret(t *testing.T) {
|
||||
claims := Claims{
|
||||
UserID: "test-user",
|
||||
Email: "test@example.com",
|
||||
Role: "user",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
|
||||
|
||||
// Admin extension endpoints
|
||||
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.POST("/extensions", extH.AdminInstallExtension)
|
||||
admin.PUT("/extensions/:id", extH.AdminUpdateExtension)
|
||||
@@ -81,16 +81,19 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
|
||||
|
||||
// Seed admin user
|
||||
adminID := seedInsertReturningID(t,
|
||||
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
"ext-admin", "ext-admin@test.com", "$2a$10$test", "admin", "ext-admin", "builtin",
|
||||
`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", "ext-admin", "builtin",
|
||||
)
|
||||
database.SeedEveryoneGroupMember(t, adminID)
|
||||
database.SeedAdminsGroupMember(t, adminID)
|
||||
adminToken := makeToken(adminID, "ext-admin@test.com", "admin")
|
||||
|
||||
// Seed regular user
|
||||
userID := seedInsertReturningID(t,
|
||||
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
"ext-user", "ext-user@test.com", "$2a$10$test", "user", "ext-user", "builtin",
|
||||
`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", "ext-user", "builtin",
|
||||
)
|
||||
database.SeedEveryoneGroupMember(t, userID)
|
||||
userToken := makeToken(userID, "ext-user@test.com", "user")
|
||||
|
||||
return &extensionHarness{
|
||||
|
||||
@@ -28,12 +28,6 @@ type updateGroupRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,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 {
|
||||
@@ -147,12 +141,6 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
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
|
||||
|
||||
@@ -65,7 +65,7 @@ func setupNotifHarness(t *testing.T) *notifHarness {
|
||||
// Admin broadcast route (v0.28.6)
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.Use(middleware.RequireAdmin(stores))
|
||||
admin.POST("/notifications/broadcast", notifH.Broadcast)
|
||||
|
||||
// Set up notification service singleton for Broadcast handler
|
||||
@@ -74,7 +74,8 @@ func setupNotifHarness(t *testing.T) *notifHarness {
|
||||
|
||||
// Seed admin
|
||||
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")
|
||||
|
||||
// Seed regular user
|
||||
|
||||
@@ -33,7 +33,6 @@ func NewProfileBootstrapHandler(s store.Stores) *ProfileBootstrapHandler {
|
||||
// GET /api/v1/profile/bootstrap
|
||||
func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
role, _ := c.Get("role")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// ── User profile ────────────────────────
|
||||
@@ -48,28 +47,21 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
|
||||
"username": user.Username,
|
||||
"display_name": user.DisplayName,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
}
|
||||
if user.AvatarURL != "" {
|
||||
userPayload["avatar"] = user.AvatarURL
|
||||
}
|
||||
|
||||
// ── Permissions ─────────────────────────
|
||||
var permList []string
|
||||
if role == "admin" {
|
||||
permList = make([]string, len(auth.AllPermissions))
|
||||
copy(permList, auth.AllPermissions)
|
||||
} else {
|
||||
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
|
||||
return
|
||||
}
|
||||
permList = make([]string, 0, len(perms))
|
||||
permList := make([]string, 0, len(perms))
|
||||
for p := range perms {
|
||||
permList = append(permList, p)
|
||||
}
|
||||
}
|
||||
sort.Strings(permList)
|
||||
|
||||
// ── Groups ──────────────────────────────
|
||||
@@ -77,7 +69,6 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
|
||||
if groupIDs == nil {
|
||||
groupIDs = []string{}
|
||||
}
|
||||
groupIDs = append(groupIDs, auth.EveryoneGroupID)
|
||||
|
||||
// ── Teams ───────────────────────────────
|
||||
teams, _ := h.stores.Teams.ListForUser(ctx, userID)
|
||||
|
||||
@@ -36,7 +36,6 @@ type profileResponse struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Settings map[string]interface{} `json:"settings"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -84,7 +83,6 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
DisplayName: dn,
|
||||
Role: user.Role,
|
||||
Avatar: avatar,
|
||||
Settings: settings,
|
||||
CreatedAt: user.CreatedAt,
|
||||
|
||||
@@ -41,11 +41,10 @@ func (h *testHarness) request(method, path, token string, body interface{}) *htt
|
||||
return w
|
||||
}
|
||||
|
||||
func makeToken(userID, email, role string) string {
|
||||
func makeToken(userID, email, _ string) string {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
|
||||
@@ -287,7 +287,6 @@ func main() {
|
||||
HeaderFingerprint: cfg.MTLSHeaderFingerprint,
|
||||
AutoActivate: cfg.MTLSAutoActivate,
|
||||
DefaultTeam: cfg.MTLSDefaultTeam,
|
||||
DefaultRole: cfg.MTLSDefaultRole,
|
||||
})
|
||||
case auth.ModeOIDC:
|
||||
var err error
|
||||
@@ -299,7 +298,6 @@ func main() {
|
||||
RedirectURL: cfg.OIDCRedirectURL,
|
||||
AutoActivate: cfg.OIDCAutoActivate,
|
||||
DefaultTeam: cfg.OIDCDefaultTeam,
|
||||
DefaultRole: cfg.OIDCDefaultRole,
|
||||
RolesClaim: cfg.OIDCRolesClaim,
|
||||
GroupsClaim: cfg.OIDCGroupsClaim,
|
||||
AdminRole: cfg.OIDCAdminRole,
|
||||
@@ -513,7 +511,7 @@ func main() {
|
||||
// ── Admin routes ────────────────────────
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.Use(middleware.RequireAdmin(stores))
|
||||
admin.Use(middleware.ValidatePathParams())
|
||||
{
|
||||
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore)
|
||||
@@ -693,7 +691,7 @@ func main() {
|
||||
Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache),
|
||||
Admin: []gin.HandlerFunc{
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -4,14 +4,19 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/auth"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// RequireAdmin returns middleware that restricts access to admin users.
|
||||
// Must be used after Auth() middleware which sets "role" in context.
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
// RequireAdmin returns middleware that restricts access to users with the
|
||||
// surface.admin.access permission (i.e. members of the Admins group).
|
||||
// Must be used after Auth() middleware which sets "user_id" in context.
|
||||
func RequireAdmin(stores store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
userID := c.GetString("user_id")
|
||||
perms, err := resolveAndCachePerms(c, stores, userID)
|
||||
if err != nil || !perms[auth.PermSurfaceAdminAccess] {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "admin access required",
|
||||
})
|
||||
|
||||
@@ -20,19 +20,17 @@ import (
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ─── User Status Cache ──────────────────────────────────────
|
||||
// Short-TTL cache so deactivation and role changes take effect
|
||||
// within seconds, without a DB query on every single request.
|
||||
// Short-TTL cache so deactivation takes effect within seconds,
|
||||
// without a DB query on every single request.
|
||||
|
||||
const userCacheTTL = 30 * time.Second
|
||||
|
||||
type userCacheEntry struct {
|
||||
isActive bool
|
||||
role string
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
@@ -64,9 +62,9 @@ func (c *UserStatusCache) get(userID string) (userCacheEntry, bool) {
|
||||
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.entries[userID] = userCacheEntry{isActive: active, role: role, fetchedAt: time.Now()}
|
||||
c.entries[userID] = userCacheEntry{isActive: active, fetchedAt: time.Now()}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -90,44 +88,43 @@ func (c *UserStatusCache) evictExpired() {
|
||||
|
||||
// ─── Shared user verification ───────────────────────────────
|
||||
|
||||
// verifyUser checks is_active and resolves the current DB role.
|
||||
// Returns (role, nil) on success or ("", error-already-sent) on failure.
|
||||
// Callers must abort the request on error.
|
||||
func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) (string, bool) {
|
||||
// verifyUser checks that the user exists and is active.
|
||||
// Returns true on success or false (with error already sent) on failure.
|
||||
func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) bool {
|
||||
userID := claims.UserID
|
||||
if userID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"})
|
||||
return "", false
|
||||
return false
|
||||
}
|
||||
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.
|
||||
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
|
||||
if entry, ok := cache.get(userID); ok {
|
||||
if !entry.isActive {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"})
|
||||
return "", false
|
||||
return false
|
||||
}
|
||||
return entry.role, true
|
||||
return true
|
||||
}
|
||||
|
||||
// Cache miss → DB lookup
|
||||
user, err := users.GetByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
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 {
|
||||
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.
|
||||
@@ -188,15 +185,13 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user is active and resolve current role from DB
|
||||
role, ok := verifyUser(c, claims, users, cache)
|
||||
if !ok {
|
||||
return // verifyUser already sent the error response
|
||||
// Verify user is active
|
||||
if !verifyUser(c, claims, users, cache) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role) // from DB, not JWT claims
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -304,14 +299,11 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
|
||||
return
|
||||
}
|
||||
|
||||
// Ticket is valid — resolve role from DB (same as JWT path)
|
||||
role, ok := verifyUserByID(c, userID, users, cache)
|
||||
if !ok {
|
||||
if !verifyUserByID(c, userID, users, cache) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", userID)
|
||||
c.Set("role", role)
|
||||
c.Set("ws_auth", "ticket")
|
||||
c.Next()
|
||||
return
|
||||
@@ -329,14 +321,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
|
||||
return
|
||||
}
|
||||
|
||||
role, ok := verifyUser(c, claims, users, cache)
|
||||
if !ok {
|
||||
if !verifyUser(c, claims, users, cache) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role)
|
||||
c.Set("ws_auth", "token_legacy")
|
||||
c.Next()
|
||||
return
|
||||
@@ -367,14 +357,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
|
||||
return
|
||||
}
|
||||
|
||||
role, ok := verifyUser(c, claims, users, cache)
|
||||
if !ok {
|
||||
if !verifyUser(c, claims, users, cache) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role)
|
||||
c.Set("ws_auth", "bearer")
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/auth"
|
||||
"switchboard-core/config"
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/store"
|
||||
@@ -60,37 +61,34 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve user role — redirect (not JSON) on any failure.
|
||||
var role string
|
||||
// Check user is active — redirect (not JSON) on failure.
|
||||
if entry, hit := cache.get(claims.UserID); hit {
|
||||
if !entry.isActive {
|
||||
redirectToLogin(c, loginPath)
|
||||
return
|
||||
}
|
||||
role = entry.role
|
||||
} else {
|
||||
user, err := users.GetByID(c.Request.Context(), claims.UserID)
|
||||
if err != nil || !user.IsActive {
|
||||
redirectToLogin(c, loginPath)
|
||||
return
|
||||
}
|
||||
cache.set(claims.UserID, user.IsActive, user.Role)
|
||||
role = user.Role
|
||||
cache.set(claims.UserID, user.IsActive)
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role)
|
||||
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.
|
||||
func RequireAdminPage() gin.HandlerFunc {
|
||||
func RequireAdminPage(stores store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
userID := c.GetString("user_id")
|
||||
perms, err := resolveAndCachePerms(c, stores, userID)
|
||||
if err != nil || !perms[auth.PermSurfaceAdminAccess] {
|
||||
c.String(http.StatusForbidden, "Admin access required")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -12,17 +12,11 @@ import (
|
||||
const permCacheKey = "resolved_permissions"
|
||||
|
||||
// RequirePermission returns middleware that enforces a named permission.
|
||||
// Admin users bypass the check entirely. Permission resolution is cached in
|
||||
// the request context — computed at most once per request regardless of how
|
||||
// many RequirePermission middlewares are chained.
|
||||
// Permission resolution is cached in the request context — computed at most
|
||||
// once per request regardless of how many RequirePermission middlewares are
|
||||
// chained. Admins receive permissions through the Admins group.
|
||||
func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role == "admin" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
perms, err := resolveAndCachePerms(c, stores, userID)
|
||||
if err != nil || !perms[perm] {
|
||||
|
||||
@@ -23,12 +23,9 @@ const (
|
||||
ScopePersonal = "personal"
|
||||
)
|
||||
|
||||
// ── Role Constants ──────────────────────────
|
||||
// ── Team Role Constants ─────────────────────
|
||||
|
||||
const (
|
||||
UserRoleUser = "user"
|
||||
UserRoleAdmin = "admin"
|
||||
|
||||
TeamRoleAdmin = "admin"
|
||||
TeamRoleMember = "member"
|
||||
)
|
||||
@@ -48,7 +45,6 @@ type User struct {
|
||||
PasswordHash string `json:"-" db:"password_hash"`
|
||||
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
|
||||
Role string `json:"role" db:"role"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
|
||||
@@ -317,9 +313,6 @@ type Group struct {
|
||||
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
|
||||
Source string `json:"source" db:"source"` // manual (default), oidc, system
|
||||
Permissions []string `json:"permissions" db:"permissions"`
|
||||
TokenBudgetDaily *int64 `json:"token_budget_daily,omitempty" db:"token_budget_daily"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -328,12 +321,6 @@ type GroupPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,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.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"switchboard-core/auth"
|
||||
"switchboard-core/events"
|
||||
"switchboard-core/models"
|
||||
"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)
|
||||
}
|
||||
|
||||
// Notify all admin users
|
||||
// Notify all Admins group members
|
||||
ctx := context.Background()
|
||||
admins, _, err := stores.Users.List(ctx, store.ListOptions{Limit: 500})
|
||||
admins, err := stores.Groups.ListMembers(ctx, auth.AdminsGroupID)
|
||||
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
|
||||
}
|
||||
|
||||
for _, u := range admins {
|
||||
if u.Role != "admin" {
|
||||
continue
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: u.ID,
|
||||
UserID: u.UserID,
|
||||
Type: models.NotifTypeRoleFallback,
|
||||
Title: title,
|
||||
Body: body,
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,9 +93,6 @@ type UserStore interface {
|
||||
|
||||
// ── 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(ctx context.Context) (int, error)
|
||||
|
||||
|
||||
@@ -43,17 +43,14 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual'),
|
||||
COALESCE(g.permissions, '[]'::jsonb),
|
||||
g.token_budget_daily,
|
||||
g.token_budget_monthly,
|
||||
g.allowed_models
|
||||
COALESCE(g.permissions, '[]'::jsonb)
|
||||
FROM groups g WHERE g.id = $1`, id)
|
||||
return scanGroup(row)
|
||||
}
|
||||
|
||||
// Update applies a patch to a group.
|
||||
// The Everyone group (source=system) allows permission/budget edits but not
|
||||
// name, description, or structural changes — those fields are silently ignored.
|
||||
// System groups (source=system) allow permission edits but not name,
|
||||
// description, or structural changes — those fields are silently ignored.
|
||||
func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error {
|
||||
var source string
|
||||
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)
|
||||
}
|
||||
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() {
|
||||
return nil
|
||||
}
|
||||
@@ -138,10 +116,7 @@ const groupSelectCols = `
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual'),
|
||||
COALESCE(g.permissions, '[]'::jsonb),
|
||||
g.token_budget_daily,
|
||||
g.token_budget_monthly,
|
||||
g.allowed_models`
|
||||
COALESCE(g.permissions, '[]'::jsonb)`
|
||||
|
||||
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `SELECT`+groupSelectCols+`
|
||||
@@ -250,28 +225,17 @@ func scanGroup(row groupScanner) (*models.Group, error) {
|
||||
var teamID sql.NullString
|
||||
var createdBy sql.NullString
|
||||
var permsJSON []byte
|
||||
var budgetDaily, budgetMonthly sql.NullInt64
|
||||
var allowedJSON []byte
|
||||
|
||||
if err := row.Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
|
||||
&permsJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
g.CreatedBy = NullableStringPtr(createdBy)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -288,28 +252,17 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
||||
var teamID sql.NullString
|
||||
var createdBy sql.NullString
|
||||
var permsJSON []byte
|
||||
var budgetDaily, budgetMonthly sql.NullInt64
|
||||
var allowedJSON []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||
&permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON,
|
||||
&permsJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
g.CreatedBy = NullableStringPtr(createdBy)
|
||||
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)
|
||||
}
|
||||
return result, rows.Err()
|
||||
|
||||
@@ -16,10 +16,10 @@ type UserStore struct{}
|
||||
func NewUserStore() *UserStore { return &UserStore{} }
|
||||
|
||||
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`
|
||||
|
||||
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`
|
||||
|
||||
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"
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings, auth_source, external_id, handle)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
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)
|
||||
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,
|
||||
).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 sj []byte
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func scanOneUser(ctx context.Context, query string, args ...interface{}) (*model
|
||||
var sj []byte
|
||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&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,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -233,13 +233,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
|
||||
|
||||
// ── 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 {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users SET settings = (
|
||||
|
||||
@@ -48,22 +48,17 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
||||
var teamID sql.NullString
|
||||
var createdBy sql.NullString
|
||||
var permsStr sql.NullString
|
||||
var budgetDaily, budgetMonthly sql.NullInt64
|
||||
var allowedStr sql.NullString
|
||||
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual'),
|
||||
COALESCE(g.permissions, '[]'),
|
||||
g.token_budget_daily,
|
||||
g.token_budget_monthly,
|
||||
g.allowed_models
|
||||
COALESCE(g.permissions, '[]')
|
||||
FROM groups g WHERE g.id = ?`, id).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
||||
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
|
||||
&permsStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -71,20 +66,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
g.CreatedBy = NullableStringPtr(createdBy)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var source string
|
||||
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))
|
||||
}
|
||||
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() {
|
||||
return nil
|
||||
}
|
||||
@@ -169,10 +136,7 @@ const groupSelectCols = `
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual'),
|
||||
COALESCE(g.permissions, '[]'),
|
||||
g.token_budget_daily,
|
||||
g.token_budget_monthly,
|
||||
g.allowed_models`
|
||||
COALESCE(g.permissions, '[]')`
|
||||
|
||||
func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
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 createdBy sql.NullString
|
||||
var permsStr sql.NullString
|
||||
var budgetDaily, budgetMonthly sql.NullInt64
|
||||
var allowedStr sql.NullString
|
||||
|
||||
if err := rows.Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
||||
&permsStr, &budgetDaily, &budgetMonthly, &allowedStr,
|
||||
&permsStr,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
g.CreatedBy = NullableStringPtr(createdBy)
|
||||
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)
|
||||
}
|
||||
return result, rows.Err()
|
||||
|
||||
@@ -16,10 +16,10 @@ type UserStore struct{}
|
||||
func NewUserStore() *UserStore { return &UserStore{} }
|
||||
|
||||
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`
|
||||
|
||||
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`
|
||||
|
||||
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"
|
||||
}
|
||||
_, 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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive,
|
||||
ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt),
|
||||
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 sj []byte
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface
|
||||
var sj []byte
|
||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&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,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -240,13 +240,6 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
|
||||
|
||||
// ── 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 {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE users SET settings = json_patch(
|
||||
|
||||
@@ -166,7 +166,7 @@ export function createDomains(restClient) {
|
||||
create: (data) => rc.post('/api/v1/admin/users', data),
|
||||
del: (id) => rc.del(`/api/v1/admin/users/${id}`),
|
||||
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 }),
|
||||
permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`),
|
||||
resetVault: (id) => rc.post(`/api/v1/admin/users/${id}/vault/reset`, {}),
|
||||
|
||||
@@ -11,7 +11,6 @@ export default function GroupsSection() {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [allPerms, setAllPerms] = useState([]);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [allModels, setAllModels] = useState([]);
|
||||
const [groupData, setGroupData] = useState({});
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
@@ -30,21 +29,16 @@ export default function GroupsSection() {
|
||||
setDetail(group);
|
||||
setGroupData({
|
||||
permissions: group.permissions || [],
|
||||
token_budget_daily: group.token_budget_daily ?? '',
|
||||
token_budget_monthly: group.token_budget_monthly ?? '',
|
||||
allowed_models: group.allowed_models || [],
|
||||
});
|
||||
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.permissions.list(),
|
||||
sw.api.admin.users.list(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
]);
|
||||
setMembers(m || []);
|
||||
setAllPerms(p.permissions || []);
|
||||
setAllUsers(u || []);
|
||||
setAllModels(models || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
@@ -63,13 +57,10 @@ export default function GroupsSection() {
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function savePermsAndBudgets() {
|
||||
async function savePermissions() {
|
||||
try {
|
||||
await sw.api.admin.groups.update(detail.id, {
|
||||
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');
|
||||
} 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) {
|
||||
e.preventDefault();
|
||||
const uid = e.target.userId.value;
|
||||
@@ -151,37 +133,8 @@ export default function GroupsSection() {
|
||||
</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;">
|
||||
<button class="btn-small btn-primary" onClick=${savePermsAndBudgets}>Save Permissions & Budgets</button>
|
||||
<button class="btn-small btn-primary" onClick=${savePermissions}>Save Permissions</button>
|
||||
</div>
|
||||
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" />
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function UsersSection() {
|
||||
username: form.username.value.trim(),
|
||||
email: form.email.value.trim(),
|
||||
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');
|
||||
try {
|
||||
@@ -52,10 +52,10 @@ export default function UsersSection() {
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function changeRole(id, role) {
|
||||
async function toggleAdmin(id, isAdmin) {
|
||||
try {
|
||||
await sw.api.admin.users.updateRole(id, role);
|
||||
sw.toast(`Role updated to ${role}`, 'success');
|
||||
await sw.api.admin.users.updateRole(id, isAdmin);
|
||||
sw.toast(isAdmin ? 'Admin granted' : 'Admin revoked', 'success');
|
||||
load();
|
||||
} 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>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>Role</label>
|
||||
<select name="role"><option value="user">User</option><option value="admin">Admin</option></select>
|
||||
<div class="form-group" style="align-self:flex-end;">
|
||||
<label class="toggle-label"><input type="checkbox" name="is_admin" /><span class="toggle-track"></span><span>Admin</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:8px;gap:8px;">
|
||||
@@ -131,17 +131,11 @@ export default function UsersSection() {
|
||||
<strong>${u.username}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span>
|
||||
${' '}${statusBadge(u)}
|
||||
${' '}<span class="badge ${u.role === 'admin' ? 'badge-admin' : ''}">${u.role}</span>
|
||||
</div>
|
||||
<div class="admin-actions-cell">
|
||||
${u.status === 'pending' && html`
|
||||
<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
|
||||
? html`<button class="btn-small" onClick=${() => toggleActive(u.id, false)}>Disable</button>`
|
||||
: html`<button class="btn-small" onClick=${() => toggleActive(u.id, true)}>Enable</button>`
|
||||
|
||||
Reference in New Issue
Block a user