# 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 1343–1371) 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
-- 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.

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

```sql
-- 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.

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