# 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 -- 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.'; ``` ### Permission Resolution A user's effective permissions are the **union** of all their groups' permissions, plus base permissions that every active user gets. ```go // server/auth/permissions.go // Base permissions granted to all active users (no group needed) var basePermissions = map[string]bool{ "model.use": true, "kb.read": true, "channel.create": true, } // ResolvePermissions returns the effective permission set for a user. func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) { perms := make(map[string]bool) // Start with base permissions for k, v := range basePermissions { perms[k] = v } // Union all group permissions 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. ### 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 019 (group permissions, token budgets, model allowlist) - `server/auth/permissions.go` (resolution logic) - `middleware/permissions.go` (`RequirePermission()`) - 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.