This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-0.24.0.md
2026-03-07 19:58:43 +00:00

1153 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design — v0.24.x: Auth Strategy + RBAC + Anonymous Sessions
**Status:** Draft
**Scope:** Auth provider abstraction, mTLS/OIDC integration, fine-grained permissions, anonymous workflow participants
**Depends on:** v0.23.0 (channel_participants, @mention routing), v0.16.0 (groups, resource grants), v0.9.4 (vault, UEK)
**Feeds into:** v0.25.0 (Workflow Engine), v0.26.0 (Autonomous Agents)
---
## Guiding Principle
Auth is the gateway to everything else. Today Switchboard has exactly
one auth mode: username/password/bcrypt/JWT. That works for single-user
and small-team deployments but blocks two critical paths:
1. Enterprise integration — orgs already have identity (Keycloak, Okta,
client certs). Requiring a separate password is a non-starter.
2. Workflow intake — anonymous visitors need to interact with channels
without registering. The workflow engine (v0.25.0) depends on this.
The design principle: **auth resolves to the same internal user model
regardless of source.** The middleware doesn't care how you proved your
identity — it cares that `c.Get("user_id")` returns a valid ID with
known capabilities. Everything downstream (teams, groups, channels,
permissions) is auth-source-agnostic.
---
## Release Decomposition
```
v0.24.0 Auth Abstraction + User Identity
┌─────┴───────────┐
│ │
v0.24.1 mTLS + OIDC v0.24.2 Fine-Grained
Providers Permissions
│ │
└─────┬───────────┘
v0.24.3 Anonymous / Session Participants
```
v0.24.1 and v0.24.2 are parallel — no dependency between them.
v0.24.3 depends on v0.24.1 (mTLS anonymous path) but the cookie-based
session path only needs v0.24.0.
---
## v0.24.0 — Auth Abstraction + User Identity
Internal plumbing. Zero behavioral change for existing deployments.
`AUTH_MODE=builtin` works identically to today but routes through a
provider interface.
### Auth Provider Interface
```go
// server/auth/provider.go
package auth
// Result is the output of any successful authentication.
// Every auth mode must produce one of these.
type Result struct {
UserID string // internal user ID (may be empty for auto-provision)
ExternalID string // IdP-specific identifier (cert fingerprint, OIDC sub)
Source string // "builtin", "mtls", "oidc"
Email string
Username string
DisplayName string
Role string // suggested role (provider can map from external claims)
Groups []string // suggested group names (OIDC claim mapping)
}
// Provider authenticates an inbound request and returns a Result.
// If the user doesn't exist locally, AutoProvision is called.
type Provider interface {
// Authenticate extracts identity from the request.
// Returns ErrUnauthenticated if no valid credentials found.
Authenticate(c *gin.Context) (*Result, error)
// SupportsLogin returns true if this provider handles
// the /login and /register routes (builtin only).
SupportsLogin() bool
}
```
This is a **request-level** interface, not a session-level one. Each
request goes through `Authenticate()`. For builtin mode, that means
JWT validation (same as today). For mTLS, header extraction. For OIDC,
bearer token validation against the IdP's JWKS.
### Builtin Provider (Refactor)
The existing auth logic in `handlers/auth.go` and `middleware/auth.go`
moves behind the interface. No new behavior — just reorganization.
```go
// server/auth/builtin.go
type BuiltinProvider struct {
cfg *config.Config
stores store.Stores
uekCache *crypto.UEKCache
}
func (p *BuiltinProvider) Authenticate(c *gin.Context) (*Result, error) {
// Existing JWT validation logic from middleware/auth.go:
// 1. Extract token from Authorization header, cookie, or query param
// 2. Parse and validate JWT with cfg.JWTSecret
// 3. Return Result with UserID, Email, Role from claims
}
func (p *BuiltinProvider) SupportsLogin() bool { return true }
```
Login/register/refresh/logout endpoints remain on `AuthHandler` but
are only mounted when `AUTH_MODE=builtin`. The `AuthHandler` gets a
reference to the provider for consistency but the routes themselves
don't change shape.
### Middleware Refactor
```go
// middleware/auth.go — after refactor
func Auth(provider auth.Provider) gin.HandlerFunc {
return func(c *gin.Context) {
if !database.IsConnected() {
c.Next()
return
}
result, err := provider.Authenticate(c)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized,
gin.H{"error": "authentication failed"})
return
}
// Set context values (same contract as today)
c.Set("user_id", result.UserID)
c.Set("email", result.Email)
c.Set("role", result.Role)
c.Set("auth_source", result.Source)
c.Next()
}
}
```
`AuthOrRedirect` (page routes) gets the same treatment — delegates to
the provider, redirects on failure. The contract with downstream
handlers is unchanged: `c.Get("user_id")`, `c.Get("role")`.
### Config Addition
```go
// config/config.go addition
type Config struct {
// ... existing fields ...
// Auth mode: "builtin" (default), "mtls", "oidc"
AuthMode string
// OIDC settings (only when AuthMode=oidc)
OIDCIssuerURL string // e.g. https://keycloak.example.com/realms/switchboard
OIDCClientID string
OIDCClientSecret string
OIDCRoleClaim string // JWT claim path for role mapping (default "realm_access.roles")
OIDCGroupsClaim string // JWT claim path for group sync (default "groups")
// mTLS settings (only when AuthMode=mtls)
MTLSHeaderDN string // header name for client cert DN (default "X-SSL-Client-DN")
MTLSHeaderVerify string // header name for verification status (default "X-SSL-Client-Verify")
// Auto-provision policy (applies to mtls and oidc)
AuthAutoActivate bool // auto-activate new users (default false)
AuthDefaultTeam string // team ID to assign new users to
AuthDefaultRole string // "user" (default) or "admin"
}
```
Validation at startup: if `AuthMode` is `mtls` or `oidc`, require the
corresponding config fields. Fail fast on missing `OIDCIssuerURL` when
`AUTH_MODE=oidc`, etc.
### User Model Extension
```go
// models/models.go — User struct additions
type User struct {
BaseModel
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
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"`
// v0.24.0 additions
Handle string `json:"handle,omitempty" db:"handle"`
AuthSource string `json:"auth_source" db:"auth_source"`
ExternalID *string `json:"external_id,omitempty" db:"external_id"`
}
```
`Handle` follows the same pattern as `personas.handle`: auto-generated
from username via `HandleFromName()`, unique index, used for @mention
resolution. The DESIGN-0_23_1 resolution order (persona exact → persona
prefix → user exact → user prefix → model exact → model prefix)
switches from matching `LOWER(username)` to matching `LOWER(handle)`
once this lands. The `resolveMention()` queries in `completion.go`
(lines 13431371) update accordingly.
`AuthSource` is always populated: `"builtin"` for existing users
(backfilled by migration), `"mtls"` or `"oidc"` for externally
provisioned users. `ExternalID` is the IdP-specific stable identifier
(OIDC `sub` claim, mTLS cert fingerprint). Nullable because builtin
users don't have one.
### Migration 018
```sql
-- 018_v024_auth.sql (Postgres)
-- ── User identity extensions ────────────────────────────────────────
ALTER TABLE users
ADD COLUMN IF NOT EXISTS handle VARCHAR(50),
ADD COLUMN IF NOT EXISTS auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin'
CHECK (auth_source IN ('builtin', 'mtls', 'oidc')),
ADD COLUMN IF NOT EXISTS external_id TEXT;
-- Backfill handles from username
UPDATE users SET handle = LOWER(REGEXP_REPLACE(username, '[^a-zA-Z0-9-]', '-', 'g'))
WHERE handle IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle
ON users(handle) WHERE handle IS NOT NULL;
-- External ID is unique per auth source
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id
ON users(auth_source, external_id) WHERE external_id IS NOT NULL;
COMMENT ON COLUMN users.handle IS 'Unique @mention handle. Auto-generated from username.';
COMMENT ON COLUMN users.auth_source IS 'Authentication provider: builtin, mtls, oidc';
COMMENT ON COLUMN users.external_id IS 'IdP-specific stable ID (OIDC sub, mTLS fingerprint)';
```
SQLite equivalent uses the same column additions with `TEXT` types and
app-side handle generation (no `REGEXP_REPLACE`).
### Auto-Provision Flow
When a non-builtin provider authenticates a user whose `external_id`
doesn't exist in the DB, auto-provision creates the account:
```go
// server/auth/provision.go
func AutoProvision(ctx context.Context, stores store.Stores, r *Result, cfg *config.Config) (*models.User, error) {
// Check if user exists by external_id
existing, _ := stores.Users.GetByExternalID(ctx, r.Source, r.ExternalID)
if existing != nil {
return existing, nil
}
// Create new user
user := &models.User{
Username: r.Username,
Email: r.Email,
DisplayName: r.DisplayName,
Handle: models.HandleFromName(r.Username),
Role: resolveRole(r.Role, cfg.AuthDefaultRole),
AuthSource: r.Source,
ExternalID: &r.ExternalID,
IsActive: cfg.AuthAutoActivate,
}
if err := stores.Users.Create(ctx, user); err != nil {
return nil, err
}
// Assign to default team if configured
if cfg.AuthDefaultTeam != "" {
stores.Teams.AddMember(ctx, cfg.AuthDefaultTeam, user.ID, "member")
}
// Sync groups from IdP claims (OIDC)
for _, groupName := range r.Groups {
syncGroupMembership(ctx, stores, user.ID, groupName)
}
return user, nil
}
```
### Store Additions
```go
// store/interfaces.go — UserStore additions
type UserStore interface {
// ... existing methods ...
GetByExternalID(ctx context.Context, authSource, externalID string) (*User, error)
GetByHandle(ctx context.Context, handle string) (*User, error)
UpdateAuthFields(ctx context.Context, id string, source string, externalID *string) error
}
```
### main.go Wiring
```go
// Construct auth provider based on AUTH_MODE
var authProvider auth.Provider
switch cfg.AuthMode {
case "mtls":
authProvider = auth.NewMTLSProvider(cfg, stores)
case "oidc":
authProvider = auth.NewOIDCProvider(cfg, stores)
default:
authProvider = auth.NewBuiltinProvider(cfg, stores, uekCache)
}
// Middleware uses the provider
api := r.Group("/api/v1")
api.Use(middleware.Auth(authProvider))
// Login/register routes only when provider supports them
if authProvider.SupportsLogin() {
public := r.Group("/api/v1")
public.POST("/register", authHandler.Register)
public.POST("/login", authHandler.Login)
// ...
}
```
### What Ships
- `server/auth/` package: `provider.go` (interface), `builtin.go`, `provision.go`
- Refactored `middleware/auth.go` and `middleware/page_auth.go`
- `AUTH_MODE` in config with validation
- Migration 018 (users.handle, users.auth_source, users.external_id)
- `resolveMention()` updated to use `handle` instead of `username`
- Autocomplete endpoint updated to include users with handle data
- Admin UI: user list shows auth_source badge
- All tests pass with `AUTH_MODE=builtin` (default) — zero regression
### What Doesn't Ship
- mTLS and OIDC providers (v0.24.1)
- Permission enforcement (v0.24.2)
- Anonymous sessions (v0.24.3)
- Admin auth configuration UI (v0.24.1)
---
## v0.24.1 — mTLS + OIDC Providers
Two concrete implementations of the `auth.Provider` interface. Each
can be developed and tested independently.
### mTLS Provider
Extracts identity from client certificate headers set by the TLS
terminator (nginx, envoy, istio). No direct cert parsing — the
reverse proxy does TLS and forwards DN/fingerprint as headers.
```go
// server/auth/mtls.go
type MTLSProvider struct {
cfg *config.Config
stores store.Stores
}
func (p *MTLSProvider) Authenticate(c *gin.Context) (*Result, error) {
// 1. Check verification header
verify := c.GetHeader(p.cfg.MTLSHeaderVerify)
if verify != "SUCCESS" {
return nil, ErrUnauthenticated
}
// 2. Parse DN from header
dn := c.GetHeader(p.cfg.MTLSHeaderDN)
if dn == "" {
return nil, ErrUnauthenticated
}
// 3. Extract fields from DN
parsed := parseDN(dn) // CN, O, OU, etc.
fingerprint := c.GetHeader("X-SSL-Client-Fingerprint")
// 4. Build result
return &Result{
ExternalID: fingerprint,
Source: "mtls",
Username: parsed.CN,
Email: parsed.Email, // from SAN if present
DisplayName: parsed.CN,
Role: "", // no role claim in certs — use default
}, nil
}
func (p *MTLSProvider) SupportsLogin() bool { return false }
```
**nginx config fragment** (documentation, not shipped code):
```nginx
server {
listen 443 ssl;
ssl_client_certificate /etc/nginx/certs/ca.pem;
ssl_verify_client on;
location /api/ {
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-Fingerprint $ssl_client_fingerprint;
proxy_pass http://backend:8080;
}
}
```
**Security constraint:** The backend must trust these headers ONLY
when they come from the known reverse proxy. In k8s this is handled
by network policy (only nginx can reach the backend pod). For
non-k8s deployments, a shared header secret or source IP check is
needed. Document this prominently — header-trust without network
isolation is a critical vulnerability.
### OIDC Provider
Validates bearer tokens against an OIDC-compliant identity provider.
Supports Keycloak, Okta, Auth0, and any spec-compliant issuer.
```go
// server/auth/oidc.go
type OIDCProvider struct {
cfg *config.Config
stores store.Stores
verifier *oidc.IDTokenVerifier // from coreos/go-oidc
}
func NewOIDCProvider(cfg *config.Config, stores store.Stores) (*OIDCProvider, error) {
// 1. Discover OIDC config from issuer URL
provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuerURL)
if err != nil {
return nil, fmt.Errorf("oidc discovery failed: %w", err)
}
// 2. Set up verifier for ID tokens
verifier := provider.Verifier(&oidc.Config{
ClientID: cfg.OIDCClientID,
})
return &OIDCProvider{cfg: cfg, stores: stores, verifier: verifier}, nil
}
func (p *OIDCProvider) Authenticate(c *gin.Context) (*Result, error) {
// 1. Extract bearer token
token := extractBearerToken(c)
if token == "" {
return nil, ErrUnauthenticated
}
// 2. Verify token
idToken, err := p.verifier.Verify(c.Request.Context(), token)
if err != nil {
return nil, ErrUnauthenticated
}
// 3. Extract claims
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
PreferredUser string `json:"preferred_username"`
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("claim extraction failed: %w", err)
}
// 4. Map roles from configurable claim path
role := extractRoleFromClaims(idToken, p.cfg.OIDCRoleClaim)
return &Result{
ExternalID: claims.Sub,
Source: "oidc",
Email: claims.Email,
Username: claims.PreferredUser,
DisplayName: claims.Name,
Role: role,
Groups: claims.Groups,
}, nil
}
func (p *OIDCProvider) SupportsLogin() bool { return false }
```
**Token flow for OIDC mode:** The frontend redirects to the IdP's
authorize endpoint. After successful auth, the IdP redirects back
with an authorization code. The backend exchanges the code for tokens
at `/auth/oidc/callback`, stores the access token in the `sb_token`
cookie, and redirects to the app. The backend validates the token on
every request via JWKS. Confidential client — the frontend never
sees the authorization code.
### OIDC Login Flow
New routes mounted only when `AUTH_MODE=oidc`:
```
GET /auth/oidc/login → redirect to IdP authorize endpoint
GET /auth/oidc/callback → exchange code, create/update user, set cookie, redirect to app
POST /auth/oidc/logout → clear cookie, optionally call IdP end_session
```
The login page (Go template) detects `AUTH_MODE` from `PageData` and
renders either the username/password form (builtin) or a "Sign in with
SSO" button (oidc/mtls).
### Group Sync on Login
When OIDC claims include group names, the auto-provision flow syncs
them to internal groups:
```go
func syncGroupMembership(ctx context.Context, stores store.Stores, userID string, groupName string) {
// Find or create group by name
group, err := stores.Groups.GetByName(ctx, groupName)
if err != nil {
group = &models.Group{
Name: groupName,
Description: "Auto-synced from IdP",
Scope: "global",
}
stores.Groups.Create(ctx, group)
}
// Add membership if not already a member
isMember, _ := stores.Groups.IsMember(ctx, group.ID, userID)
if !isMember {
stores.Groups.AddMember(ctx, group.ID, userID, "system")
}
}
```
This runs on every login, not just first provision. Group removal
is **not** auto-synced — if a user loses a group in the IdP, they
keep it locally until an admin removes it. Aggressive auto-removal
risks breaking resource grants on transient IdP outages.
### Vault Passphrase for External Auth
When `AUTH_MODE` is not `builtin`, users don't have a password. The
vault's UEK (per-user encryption key) is derived from the user's
password via Argon2id (v0.9.4). External auth users can't derive a
UEK this way.
Two options, one for each deployment scenario:
**Personal BYOK not needed** (enterprise, team providers only):
External auth users simply don't get a UEK. Personal provider
configs aren't available. The vault operates with the global
`ENCRYPTION_KEY` only. No user action needed.
**Personal BYOK needed** (mixed deployment): The user sets an
optional vault passphrase via Settings. This passphrase is
unrelated to their IdP credentials — it exists solely to derive
the UEK. The existing `crypto.DeriveUEK()` and `crypto.UEKCache`
work unchanged; the input is just a passphrase instead of a
login password. UI: a "Vault Passphrase" field in user settings,
prompted on first use of a personal provider.
Implementation: add `has_vault_passphrase` bool to user model.
When true, the settings page prompts for the passphrase to unlock
personal providers. The UEK derivation and cache machinery is
unchanged — only the source of the passphrase differs.
### Store Additions
```go
// store/interfaces.go — GroupStore addition
type GroupStore interface {
// ... existing methods ...
GetByName(ctx context.Context, name string) (*models.Group, error)
}
```
### Admin UI
Auth provider configuration panel in admin settings:
- Display current `AUTH_MODE` (read-only — set by env var, not UI)
- OIDC section (when mode=oidc): issuer URL, client ID, role claim
path, groups claim path — read from config, displayed for reference
- mTLS section (when mode=mtls): trusted header names, verification
requirements — read from config
- Auto-provision policy: auto-activate toggle, default team selector,
default role dropdown
- User list: auth source badge, external ID shown in user detail panel
### What Ships
- `server/auth/mtls.go`, `server/auth/oidc.go`
- `/auth/oidc/login`, `/auth/oidc/callback`, `/auth/oidc/logout` routes
- Login page template adapts to `AUTH_MODE`
- Group sync on OIDC login
- Vault passphrase option for external auth users
- Admin auth configuration panel
- `GroupStore.GetByName()` added to both Postgres and SQLite stores
- New Go dependency: `github.com/coreos/go-oidc/v3`
### What Doesn't Ship
- Fine-grained permissions (v0.24.2)
- Anonymous/session participants (v0.24.3)
---
## v0.24.2 — Fine-Grained Permissions
Groups (v0.16.0) currently scope resource visibility — which personas
and KBs a user can access. This release makes groups carry
**permissions** — what actions a user can perform.
### Permission Model
Permissions are named strings. The system defines a fixed set;
they're not user-configurable (no "create your own permission"
complexity). Each permission has a scope: global or resource-specific.
```
model.use — use models for completion (implicit today)
model.select_any — use any enabled model (vs. group-restricted set)
kb.read — search KBs through personas
kb.write — upload/delete KB documents
kb.create — create new knowledge bases
channel.create — create group/channel conversations
channel.invite — invite users to channels
persona.create — create new personas
persona.manage — edit/delete team personas
workflow.create — create workflow definitions (v0.25.0)
admin.view — read-only admin panel access
token.unlimited — bypass token budgets
```
### Storage
Permissions are stored as a JSONB array on the group, not in a
separate table. This avoids a join-heavy permission resolution
path and keeps the grant model simple.
```sql
-- 019_v024_permissions.sql
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Token budgets per group
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS token_budget_daily BIGINT,
ADD COLUMN IF NOT EXISTS token_budget_monthly BIGINT;
-- Model allowlist per group (NULL = no restriction)
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS allowed_models JSONB;
COMMENT ON COLUMN groups.permissions IS 'Array of permission strings granted to group members';
COMMENT ON COLUMN groups.allowed_models IS 'Array of model_id strings this group can use. NULL = unrestricted.';
```
### The Everyone Group
Rather than hardcoding base permissions in application code, the system
seeds a special **Everyone** group at migration time. Every authenticated
user implicitly receives its permissions without explicit membership.
Properties:
- `id` = `00000000-0000-0000-0000-000000000001` (stable, well-known)
- `name` = `"Everyone"`
- `scope` = `"global"`
- `source` = `"system"` (guards against deletion)
The Everyone group is editable in the admin UI — an org that wants
locked-down defaults empties it; an open install leaves it permissive.
`DefaultUserPerms` via `global_config` (previously in ROADMAP) is
superseded by this approach: it was a one-time init value anyway.
The migration seeds the group; subsequent changes are live data.
`Delete` on a `source=system` group returns `400 Bad Request`. The guard
lives in the store layer, not the handler, so it can't be bypassed.
### Permission Resolution
A user's effective permissions are the **union** of the Everyone group's
permissions plus all their explicitly assigned groups' permissions.
```go
// server/auth/permissions.go
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
// ResolvePermissions returns the effective permission set for a user.
// Always includes the Everyone group regardless of membership.
// Admin users bypass this entirely — callers should check role first.
func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
perms := make(map[string]bool)
// Always apply Everyone group
everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID)
if 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
}
for _, g := range groups {
for _, p := range g.Permissions {
perms[p] = true
}
}
return perms, nil
}
```
Admin users (`role=admin`) bypass permission checks entirely, same
as they bypass team membership checks today.
### BYOK Budget Bypass
Token budget enforcement only applies when the completion draws from a
**team or global provider**. If `providerScope == "personal"` the request
is the user's own money — budget pre-flight is skipped entirely. The
`providerScope` value is already resolved by `resolveConfig()` before
the completion handler begins streaming.
### Anonymous Sessions
v0.24.3 session participants carry `SessionClaims`, not `UserClaims`.
They never enter group-based permission resolution. Their capability is
bounded to their channel and the persona bound to it by the workflow
definition. Do not conflate the two paths.
### Middleware Helper
```go
// middleware/permissions.go
func RequirePermission(perm string) gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get("role")
if role == "admin" {
c.Next()
return
}
userID := c.GetString("user_id")
perms, _ := resolveAndCachePermissions(c, userID)
if !perms[perm] {
c.AbortWithStatusJSON(http.StatusForbidden,
gin.H{"error": fmt.Sprintf("permission required: %s", perm)})
return
}
c.Next()
}
}
```
Permission resolution is cached in the request context (computed
once per request, not per middleware). For performance, a TTL cache
keyed by user ID avoids hitting the DB on every request — invalidated
on group membership changes via the EventBus.
### Model Access Control
When a group has `allowed_models` set, members can only use models
in that list. Enforced in two places:
1. **Model list endpoint** — filters response to only include models
the user is allowed to use. Union of all groups' allowed_models.
NULL allowed_models means "no restriction" (doesn't restrict, even
if another group does restrict).
2. **Completion handler** — checks requested model against the user's
effective model allowlist before streaming.
### Token Budgets
Per-group daily and monthly token limits. The **most restrictive**
budget applies (not union — intersection). Checked in the completion
handler before streaming begins. Usage is already tracked in
`usage_log` (v0.10.0) — this adds a pre-flight check.
```go
// In completion handler, before streaming
budget, err := auth.ResolveTokenBudget(ctx, stores, userID)
if budget != nil {
used, _ := stores.Usage.GetUserUsage(ctx, userID, budget.Period)
if used + estimatedTokens > budget.Limit {
c.JSON(429, gin.H{"error": "token budget exceeded"})
return
}
}
```
### Handler Integration
Existing handlers that need permission gates:
| Handler | Current Gate | New Gate |
|---------|-------------|----------|
| `POST /personas` | `RequireAdmin()` | `RequirePermission("persona.create")` |
| `PUT/DELETE /personas/:id` | `RequireAdmin()` | `RequirePermission("persona.manage")` |
| `POST /knowledge-bases` | `RequireAdmin()` | `RequirePermission("kb.create")` |
| `POST /knowledge-bases/:id/documents` | team check | `RequirePermission("kb.write")` |
| `POST /channels` (type=channel) | auth only | `RequirePermission("channel.create")` |
| `POST /channels/:id/participants` | auth only | `RequirePermission("channel.invite")` |
The existing `RequireAdmin()` and `RequireTeamAdmin()` middleware
remain. `RequirePermission()` is additive — a route can have both
team membership and permission requirements.
### Admin UI
Group edit form gains:
- Permissions section: checkbox list of available permissions
- Token budget fields: daily limit, monthly limit (0 = unlimited)
- Model allowlist: multi-select from enabled models (empty = unrestricted)
- User detail panel: "Effective Permissions" read-only summary showing
the union of all group grants
### What Ships
- Migration 020 (group permissions, token budgets, model allowlist, Everyone group seed)
- `server/auth/permissions.go` (constants, resolution logic, budget resolution)
- `middleware/permissions.go` (`RequirePermission()` with request-context cache)
- Permission-gated handler routes
- Token budget pre-flight in completion handler (skipped for personal/BYOK providers)
- Model access filtering in model list endpoint
- Admin UI: group permissions editor, user effective permissions view
- Both Postgres and SQLite store implementations
- Everyone group: seeded in migration, guarded from deletion in store layer
- Permission-gated handler routes
- Token budget pre-flight in completion handler
- Model access filtering in model list endpoint
- Admin UI: group permissions editor, user effective permissions view
- Both Postgres and SQLite store implementations
### What Doesn't Ship
- Anonymous sessions (v0.24.3)
- Permission UI for non-admin users (future — "why can't I use this model?")
- Custom permission definitions (intentionally out of scope)
---
## v0.24.3 — Anonymous / Session Participants
Unauthenticated visitors can interact with workflow channels.
This is the bridge to v0.25.0 (Workflow Engine).
### Session Identity
A session participant is a `channel_participants` row with
`participant_type = 'session'`. It has no `users` row — identity
exists only within the channel scope.
```go
// models/models.go
type SessionParticipant struct {
BaseModel
SessionToken string `json:"session_token" db:"session_token"`
ChannelID string `json:"channel_id" db:"channel_id"`
DisplayName string `json:"display_name" db:"display_name"`
Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"`
}
```
### Migration 020
```sql
-- 020_v024_sessions.sql
CREATE TABLE IF NOT EXISTS session_participants (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
session_token TEXT NOT NULL UNIQUE,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
display_name TEXT NOT NULL DEFAULT 'Visitor',
fingerprint TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_session_participants_channel
ON session_participants(channel_id);
CREATE INDEX IF NOT EXISTS idx_session_participants_token
ON session_participants(session_token);
COMMENT ON TABLE session_participants IS 'Ephemeral identities for anonymous workflow channel visitors.';
```
### Session Creation
Two entry paths:
**Cookie-based (default):** Visitor hits a workflow channel URL
(`/w/:slug`). If no `sb_session` cookie exists, the server generates
a session token, creates a `session_participants` row scoped to that
channel, sets the cookie, and renders the page. Subsequent requests
include the cookie.
**mTLS-based:** When `AUTH_MODE=mtls`, the cert fingerprint is used
as the session token instead of a random value. This gives stable
identity across browser sessions without requiring a `users` row.
The `fingerprint` column stores the cert fingerprint for display to
team members.
```go
// server/auth/session.go
func CreateOrResumeSession(c *gin.Context, stores store.Stores, channelID string, cfg *config.Config) (*SessionParticipant, error) {
// Check for existing session cookie
token, _ := c.Cookie("sb_session")
// mTLS mode: use cert fingerprint as stable token
if cfg.AuthMode == "mtls" {
fp := c.GetHeader("X-SSL-Client-Fingerprint")
if fp != "" {
token = "mtls:" + fp
}
}
// Resume existing session
if token != "" {
session, err := stores.Sessions.GetByToken(c.Request.Context(), token)
if err == nil && session.ChannelID == channelID {
return session, nil
}
}
// Create new session
token = "sess:" + uuid.NewString()
session := &SessionParticipant{
SessionToken: token,
ChannelID: channelID,
DisplayName: generateVisitorName(channelID), // "Visitor #3"
}
if err := stores.Sessions.Create(c.Request.Context(), session); err != nil {
return nil, err
}
// Set cookie (httponly, 30 day expiry)
c.SetCookie("sb_session", token, 60*60*24*30, "/", "", true, true)
// Add as channel participant
stores.Channels.AddParticipant(c.Request.Context(), channelID, &models.ChannelParticipant{
ChannelID: channelID,
ParticipantType: "session",
ParticipantID: session.ID,
Role: "visitor",
})
return session, nil
}
```
### Scoping Constraints
Session participants are intentionally limited:
- **Channel-scoped:** A session token is valid for exactly one channel.
Visiting a different workflow channel creates a separate session.
- **Workflow channels only:** Session auth is only accepted on channels
with `type = 'workflow'`. Attempting to session-auth into a `direct`,
`group`, or `channel` type returns 403.
- **Message + tool calls only:** Session participants can send messages
and trigger tool calls within their channel. They cannot list other
channels, access the sidebar, manage settings, or call any endpoint
outside their channel scope.
- **No @mention resolution:** Session participants cannot be @mentioned
(they have no handle). They're message producers, not routing targets.
### Session Auth Middleware
A new middleware variant for workflow entry points:
```go
// middleware/session_auth.go
func AuthOrSession(provider auth.Provider, stores store.Stores, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Try normal auth first
result, err := provider.Authenticate(c)
if err == nil {
c.Set("user_id", result.UserID)
c.Set("role", result.Role)
c.Set("auth_type", "user")
c.Next()
return
}
// Fall back to session auth for workflow channels
channelID := c.Param("channelId")
if channelID == "" {
c.AbortWithStatusJSON(401, gin.H{"error": "authentication required"})
return
}
session, err := CreateOrResumeSession(c, stores, channelID, cfg)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "session creation failed"})
return
}
c.Set("session_id", session.ID)
c.Set("session_token", session.SessionToken)
c.Set("channel_id", channelID)
c.Set("auth_type", "session")
c.Next()
}
}
```
Handlers that support session participants check `c.GetString("auth_type")`
and enforce channel scope when it's `"session"`.
### Team Member View
When a team member is assigned to a workflow channel, they see
session participant activity attributed to the session identity:
- Display name: "Visitor #3" (auto-generated) or cert CN (mTLS mode)
- Messages show session avatar (generic person icon)
- Session metadata visible in participant list panel
### What Ships
- Migration 020 (session_participants table)
- `server/auth/session.go` (session creation/resume)
- `middleware/session_auth.go` (AuthOrSession)
- Session-scoped message and completion handlers
- Workflow channel entry page (Go template: `/w/:slug`)
- SessionStore interface + Postgres/SQLite implementations
- Session participant rendering in chat UI
### What Doesn't Ship
- Workflow definitions and stage transitions (v0.25.0)
- Session-to-user promotion ("create an account from your session")
- Session expiry and cleanup (future housekeeping job)
---
## Cross-Cutting Concerns
### Testing Strategy
Each subversion is independently testable:
- **v0.24.0**: Existing integration tests run unchanged with
`AUTH_MODE=builtin`. New tests verify the provider interface
contract and handle backfill migration.
- **v0.24.1**: Mock IdP for OIDC tests (test JWKS server). mTLS
tested by injecting headers directly. Auto-provision flow tested
end-to-end against both Postgres and SQLite.
- **v0.24.2**: Permission resolution unit tests. Handler integration
tests with group-permission fixtures. Token budget enforcement
with mock usage data.
- **v0.24.3**: Session lifecycle tests (create, resume, scope
enforcement). Workflow channel entry test with session-only auth.
### Migration Sequence
```
018_v024_auth.sql — users.handle, auth_source, external_id
019_v024_permissions.sql — groups.permissions, token_budget, allowed_models
020_v024_sessions.sql — session_participants table
```
All three are additive (new columns, new tables). No destructive
changes. Can be applied sequentially as each subversion merges.
SQLite equivalents for each.
### Backward Compatibility
`AUTH_MODE=builtin` (default) requires zero configuration changes.
Existing deployments continue to work with no new env vars. The
migration backfills `auth_source='builtin'` and auto-generates
handles — existing users don't notice anything. Permission
resolution with empty group permissions returns base permissions
only — same effective access as today.
---
## Relationship to Future Milestones
**v0.25.0 Workflow Engine:** Depends on v0.24.3 (anonymous sessions)
for the visitor intake path and v0.24.2 (permissions) for
`workflow.create`. Channel `ai_mode` (v0.23.2) maps to
workflow stage behavior. Everything composes.
**v0.26.0 Autonomous Agents:** Service channels (`type='service'`)
run with no human participants. Auth isn't involved — the scheduler
creates channels with system identity. Token budgets from v0.24.2
apply as execution budgets.
---
## Resolved Decisions
1. **OIDC client type:** Confidential with backend callback at
`/auth/oidc/callback`. The frontend never sees the authorization
code. PKCE (public client) is deferred — unnecessary for a
server-side app.
2. **Permission inheritance through teams:** No implicit team
permissions. Teams scope visibility, groups grant capabilities.
The two remain orthogonal. A team admin who wants all members to
have `channel.create` creates a group, assigns the permission,
and adds the team members.
3. **Group priority for token budgets:** Most-restrictive wins.
No "override" group type. Safe and predictable. Admins who need
exceptions create a dedicated group with a higher budget.
4. **Handle collision on auto-provision:** Append numeric suffix
(`-2`, `-3`) until unique. Same logic as persona handle generation
via `HandleFromName()`. The unique index is the backstop.
5. **Session cleanup:** Deferred. Table is append-only and low-volume
until workflows are heavily used. A cleanup CronJob (delete sessions
older than N days with no messages) is a natural addition in v0.25.0
when workflow channels are actively creating sessions.