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 20:49:23 +00:00

39 KiB
Raw Blame History

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

// 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.

// 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

// 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

// 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

// 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

-- 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:

// 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

// 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

// 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.

// 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):

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.

// 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:

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

// 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.

-- 020_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.

// 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

// 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.

// 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.

// 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 021

-- 021_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.

// 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:

// 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 021 (session_participants table, channels.allow_anonymous)
  • 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_oidc.sql          — oidc_auth_state, groups.source
020_permissions.sql        — groups.permissions, token_budget, allowed_models, Everyone group
021_sessions.sql           — session_participants table, channels.allow_anonymous

All four 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.