1153 lines
39 KiB
Markdown
1153 lines
39 KiB
Markdown
# 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.';
|
||
```
|
||
|
||
### The Everyone Group
|
||
|
||
Rather than hardcoding base permissions in application code, the system
|
||
seeds a special **Everyone** group at migration time. Every authenticated
|
||
user implicitly receives its permissions without explicit membership.
|
||
|
||
Properties:
|
||
- `id` = `00000000-0000-0000-0000-000000000001` (stable, well-known)
|
||
- `name` = `"Everyone"`
|
||
- `scope` = `"global"`
|
||
- `source` = `"system"` (guards against deletion)
|
||
|
||
The Everyone group is editable in the admin UI — an org that wants
|
||
locked-down defaults empties it; an open install leaves it permissive.
|
||
`DefaultUserPerms` via `global_config` (previously in ROADMAP) is
|
||
superseded by this approach: it was a one-time init value anyway.
|
||
The migration seeds the group; subsequent changes are live data.
|
||
|
||
`Delete` on a `source=system` group returns `400 Bad Request`. The guard
|
||
lives in the store layer, not the handler, so it can't be bypassed.
|
||
|
||
### Permission Resolution
|
||
|
||
A user's effective permissions are the **union** of the Everyone group's
|
||
permissions plus all their explicitly assigned groups' permissions.
|
||
|
||
```go
|
||
// server/auth/permissions.go
|
||
|
||
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
|
||
|
||
// ResolvePermissions returns the effective permission set for a user.
|
||
// Always includes the Everyone group regardless of membership.
|
||
// Admin users bypass this entirely — callers should check role first.
|
||
func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||
perms := make(map[string]bool)
|
||
|
||
// Always apply Everyone group
|
||
everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID)
|
||
if err == nil {
|
||
for _, p := range everyone.Permissions {
|
||
perms[p] = true
|
||
}
|
||
}
|
||
|
||
// Union all explicit group memberships
|
||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||
if err != nil {
|
||
return perms, err
|
||
}
|
||
for _, g := range groups {
|
||
for _, p := range g.Permissions {
|
||
perms[p] = true
|
||
}
|
||
}
|
||
|
||
return perms, nil
|
||
}
|
||
```
|
||
|
||
Admin users (`role=admin`) bypass permission checks entirely, same
|
||
as they bypass team membership checks today.
|
||
|
||
### BYOK Budget Bypass
|
||
|
||
Token budget enforcement only applies when the completion draws from a
|
||
**team or global provider**. If `providerScope == "personal"` the request
|
||
is the user's own money — budget pre-flight is skipped entirely. The
|
||
`providerScope` value is already resolved by `resolveConfig()` before
|
||
the completion handler begins streaming.
|
||
|
||
### Anonymous Sessions
|
||
|
||
v0.24.3 session participants carry `SessionClaims`, not `UserClaims`.
|
||
They never enter group-based permission resolution. Their capability is
|
||
bounded to their channel and the persona bound to it by the workflow
|
||
definition. Do not conflate the two paths.
|
||
|
||
### Middleware Helper
|
||
|
||
```go
|
||
// middleware/permissions.go
|
||
|
||
func RequirePermission(perm string) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
role, _ := c.Get("role")
|
||
if role == "admin" {
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
userID := c.GetString("user_id")
|
||
perms, _ := resolveAndCachePermissions(c, userID)
|
||
if !perms[perm] {
|
||
c.AbortWithStatusJSON(http.StatusForbidden,
|
||
gin.H{"error": fmt.Sprintf("permission required: %s", perm)})
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
```
|
||
|
||
Permission resolution is cached in the request context (computed
|
||
once per request, not per middleware). For performance, a TTL cache
|
||
keyed by user ID avoids hitting the DB on every request — invalidated
|
||
on group membership changes via the EventBus.
|
||
|
||
### Model Access Control
|
||
|
||
When a group has `allowed_models` set, members can only use models
|
||
in that list. Enforced in two places:
|
||
|
||
1. **Model list endpoint** — filters response to only include models
|
||
the user is allowed to use. Union of all groups' allowed_models.
|
||
NULL allowed_models means "no restriction" (doesn't restrict, even
|
||
if another group does restrict).
|
||
2. **Completion handler** — checks requested model against the user's
|
||
effective model allowlist before streaming.
|
||
|
||
### Token Budgets
|
||
|
||
Per-group daily and monthly token limits. The **most restrictive**
|
||
budget applies (not union — intersection). Checked in the completion
|
||
handler before streaming begins. Usage is already tracked in
|
||
`usage_log` (v0.10.0) — this adds a pre-flight check.
|
||
|
||
```go
|
||
// In completion handler, before streaming
|
||
budget, err := auth.ResolveTokenBudget(ctx, stores, userID)
|
||
if budget != nil {
|
||
used, _ := stores.Usage.GetUserUsage(ctx, userID, budget.Period)
|
||
if used + estimatedTokens > budget.Limit {
|
||
c.JSON(429, gin.H{"error": "token budget exceeded"})
|
||
return
|
||
}
|
||
}
|
||
```
|
||
|
||
### Handler Integration
|
||
|
||
Existing handlers that need permission gates:
|
||
|
||
| Handler | Current Gate | New Gate |
|
||
|---------|-------------|----------|
|
||
| `POST /personas` | `RequireAdmin()` | `RequirePermission("persona.create")` |
|
||
| `PUT/DELETE /personas/:id` | `RequireAdmin()` | `RequirePermission("persona.manage")` |
|
||
| `POST /knowledge-bases` | `RequireAdmin()` | `RequirePermission("kb.create")` |
|
||
| `POST /knowledge-bases/:id/documents` | team check | `RequirePermission("kb.write")` |
|
||
| `POST /channels` (type=channel) | auth only | `RequirePermission("channel.create")` |
|
||
| `POST /channels/:id/participants` | auth only | `RequirePermission("channel.invite")` |
|
||
|
||
The existing `RequireAdmin()` and `RequireTeamAdmin()` middleware
|
||
remain. `RequirePermission()` is additive — a route can have both
|
||
team membership and permission requirements.
|
||
|
||
### Admin UI
|
||
|
||
Group edit form gains:
|
||
|
||
- Permissions section: checkbox list of available permissions
|
||
- Token budget fields: daily limit, monthly limit (0 = unlimited)
|
||
- Model allowlist: multi-select from enabled models (empty = unrestricted)
|
||
- User detail panel: "Effective Permissions" read-only summary showing
|
||
the union of all group grants
|
||
|
||
### What Ships
|
||
|
||
- Migration 020 (group permissions, token budgets, model allowlist, Everyone group seed)
|
||
- `server/auth/permissions.go` (constants, resolution logic, budget resolution)
|
||
- `middleware/permissions.go` (`RequirePermission()` with request-context cache)
|
||
- Permission-gated handler routes
|
||
- Token budget pre-flight in completion handler (skipped for personal/BYOK providers)
|
||
- Model access filtering in model list endpoint
|
||
- Admin UI: group permissions editor, user effective permissions view
|
||
- Both Postgres and SQLite store implementations
|
||
- Everyone group: seeded in migration, guarded from deletion in store layer
|
||
- Permission-gated handler routes
|
||
- Token budget pre-flight in completion handler
|
||
- Model access filtering in model list endpoint
|
||
- Admin UI: group permissions editor, user effective permissions view
|
||
- Both Postgres and SQLite store implementations
|
||
|
||
### What Doesn't Ship
|
||
|
||
- Anonymous sessions (v0.24.3)
|
||
- Permission UI for non-admin users (future — "why can't I use this model?")
|
||
- Custom permission definitions (intentionally out of scope)
|
||
|
||
---
|
||
|
||
## v0.24.3 — Anonymous / Session Participants
|
||
|
||
Unauthenticated visitors can interact with workflow channels.
|
||
This is the bridge to v0.25.0 (Workflow Engine).
|
||
|
||
### Session Identity
|
||
|
||
A session participant is a `channel_participants` row with
|
||
`participant_type = 'session'`. It has no `users` row — identity
|
||
exists only within the channel scope.
|
||
|
||
```go
|
||
// models/models.go
|
||
|
||
type SessionParticipant struct {
|
||
BaseModel
|
||
SessionToken string `json:"session_token" db:"session_token"`
|
||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||
DisplayName string `json:"display_name" db:"display_name"`
|
||
Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"`
|
||
}
|
||
```
|
||
|
||
### Migration 020
|
||
|
||
```sql
|
||
-- 020_v024_sessions.sql
|
||
|
||
CREATE TABLE IF NOT EXISTS session_participants (
|
||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||
session_token TEXT NOT NULL UNIQUE,
|
||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||
display_name TEXT NOT NULL DEFAULT 'Visitor',
|
||
fingerprint TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_session_participants_channel
|
||
ON session_participants(channel_id);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_session_participants_token
|
||
ON session_participants(session_token);
|
||
|
||
COMMENT ON TABLE session_participants IS 'Ephemeral identities for anonymous workflow channel visitors.';
|
||
```
|
||
|
||
### Session Creation
|
||
|
||
Two entry paths:
|
||
|
||
**Cookie-based (default):** Visitor hits a workflow channel URL
|
||
(`/w/:slug`). If no `sb_session` cookie exists, the server generates
|
||
a session token, creates a `session_participants` row scoped to that
|
||
channel, sets the cookie, and renders the page. Subsequent requests
|
||
include the cookie.
|
||
|
||
**mTLS-based:** When `AUTH_MODE=mtls`, the cert fingerprint is used
|
||
as the session token instead of a random value. This gives stable
|
||
identity across browser sessions without requiring a `users` row.
|
||
The `fingerprint` column stores the cert fingerprint for display to
|
||
team members.
|
||
|
||
```go
|
||
// server/auth/session.go
|
||
|
||
func CreateOrResumeSession(c *gin.Context, stores store.Stores, channelID string, cfg *config.Config) (*SessionParticipant, error) {
|
||
// Check for existing session cookie
|
||
token, _ := c.Cookie("sb_session")
|
||
|
||
// mTLS mode: use cert fingerprint as stable token
|
||
if cfg.AuthMode == "mtls" {
|
||
fp := c.GetHeader("X-SSL-Client-Fingerprint")
|
||
if fp != "" {
|
||
token = "mtls:" + fp
|
||
}
|
||
}
|
||
|
||
// Resume existing session
|
||
if token != "" {
|
||
session, err := stores.Sessions.GetByToken(c.Request.Context(), token)
|
||
if err == nil && session.ChannelID == channelID {
|
||
return session, nil
|
||
}
|
||
}
|
||
|
||
// Create new session
|
||
token = "sess:" + uuid.NewString()
|
||
session := &SessionParticipant{
|
||
SessionToken: token,
|
||
ChannelID: channelID,
|
||
DisplayName: generateVisitorName(channelID), // "Visitor #3"
|
||
}
|
||
if err := stores.Sessions.Create(c.Request.Context(), session); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Set cookie (httponly, 30 day expiry)
|
||
c.SetCookie("sb_session", token, 60*60*24*30, "/", "", true, true)
|
||
|
||
// Add as channel participant
|
||
stores.Channels.AddParticipant(c.Request.Context(), channelID, &models.ChannelParticipant{
|
||
ChannelID: channelID,
|
||
ParticipantType: "session",
|
||
ParticipantID: session.ID,
|
||
Role: "visitor",
|
||
})
|
||
|
||
return session, nil
|
||
}
|
||
```
|
||
|
||
### Scoping Constraints
|
||
|
||
Session participants are intentionally limited:
|
||
|
||
- **Channel-scoped:** A session token is valid for exactly one channel.
|
||
Visiting a different workflow channel creates a separate session.
|
||
- **Workflow channels only:** Session auth is only accepted on channels
|
||
with `type = 'workflow'`. Attempting to session-auth into a `direct`,
|
||
`group`, or `channel` type returns 403.
|
||
- **Message + tool calls only:** Session participants can send messages
|
||
and trigger tool calls within their channel. They cannot list other
|
||
channels, access the sidebar, manage settings, or call any endpoint
|
||
outside their channel scope.
|
||
- **No @mention resolution:** Session participants cannot be @mentioned
|
||
(they have no handle). They're message producers, not routing targets.
|
||
|
||
### Session Auth Middleware
|
||
|
||
A new middleware variant for workflow entry points:
|
||
|
||
```go
|
||
// middleware/session_auth.go
|
||
|
||
func AuthOrSession(provider auth.Provider, stores store.Stores, cfg *config.Config) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// Try normal auth first
|
||
result, err := provider.Authenticate(c)
|
||
if err == nil {
|
||
c.Set("user_id", result.UserID)
|
||
c.Set("role", result.Role)
|
||
c.Set("auth_type", "user")
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// Fall back to session auth for workflow channels
|
||
channelID := c.Param("channelId")
|
||
if channelID == "" {
|
||
c.AbortWithStatusJSON(401, gin.H{"error": "authentication required"})
|
||
return
|
||
}
|
||
|
||
session, err := CreateOrResumeSession(c, stores, channelID, cfg)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(401, gin.H{"error": "session creation failed"})
|
||
return
|
||
}
|
||
|
||
c.Set("session_id", session.ID)
|
||
c.Set("session_token", session.SessionToken)
|
||
c.Set("channel_id", channelID)
|
||
c.Set("auth_type", "session")
|
||
c.Next()
|
||
}
|
||
}
|
||
```
|
||
|
||
Handlers that support session participants check `c.GetString("auth_type")`
|
||
and enforce channel scope when it's `"session"`.
|
||
|
||
### Team Member View
|
||
|
||
When a team member is assigned to a workflow channel, they see
|
||
session participant activity attributed to the session identity:
|
||
|
||
- Display name: "Visitor #3" (auto-generated) or cert CN (mTLS mode)
|
||
- Messages show session avatar (generic person icon)
|
||
- Session metadata visible in participant list panel
|
||
|
||
### What Ships
|
||
|
||
- Migration 020 (session_participants table)
|
||
- `server/auth/session.go` (session creation/resume)
|
||
- `middleware/session_auth.go` (AuthOrSession)
|
||
- Session-scoped message and completion handlers
|
||
- Workflow channel entry page (Go template: `/w/:slug`)
|
||
- SessionStore interface + Postgres/SQLite implementations
|
||
- Session participant rendering in chat UI
|
||
|
||
### What Doesn't Ship
|
||
|
||
- Workflow definitions and stage transitions (v0.25.0)
|
||
- Session-to-user promotion ("create an account from your session")
|
||
- Session expiry and cleanup (future housekeeping job)
|
||
|
||
---
|
||
|
||
## Cross-Cutting Concerns
|
||
|
||
### Testing Strategy
|
||
|
||
Each subversion is independently testable:
|
||
|
||
- **v0.24.0**: Existing integration tests run unchanged with
|
||
`AUTH_MODE=builtin`. New tests verify the provider interface
|
||
contract and handle backfill migration.
|
||
- **v0.24.1**: Mock IdP for OIDC tests (test JWKS server). mTLS
|
||
tested by injecting headers directly. Auto-provision flow tested
|
||
end-to-end against both Postgres and SQLite.
|
||
- **v0.24.2**: Permission resolution unit tests. Handler integration
|
||
tests with group-permission fixtures. Token budget enforcement
|
||
with mock usage data.
|
||
- **v0.24.3**: Session lifecycle tests (create, resume, scope
|
||
enforcement). Workflow channel entry test with session-only auth.
|
||
|
||
### Migration Sequence
|
||
|
||
```
|
||
018_v024_auth.sql — users.handle, auth_source, external_id
|
||
019_v024_permissions.sql — groups.permissions, token_budget, allowed_models
|
||
020_v024_sessions.sql — session_participants table
|
||
```
|
||
|
||
All three are additive (new columns, new tables). No destructive
|
||
changes. Can be applied sequentially as each subversion merges.
|
||
SQLite equivalents for each.
|
||
|
||
### Backward Compatibility
|
||
|
||
`AUTH_MODE=builtin` (default) requires zero configuration changes.
|
||
Existing deployments continue to work with no new env vars. The
|
||
migration backfills `auth_source='builtin'` and auto-generates
|
||
handles — existing users don't notice anything. Permission
|
||
resolution with empty group permissions returns base permissions
|
||
only — same effective access as today.
|
||
|
||
---
|
||
|
||
## Relationship to Future Milestones
|
||
|
||
**v0.25.0 Workflow Engine:** Depends on v0.24.3 (anonymous sessions)
|
||
for the visitor intake path and v0.24.2 (permissions) for
|
||
`workflow.create`. Channel `ai_mode` (v0.23.2) maps to
|
||
workflow stage behavior. Everything composes.
|
||
|
||
**v0.26.0 Autonomous Agents:** Service channels (`type='service'`)
|
||
run with no human participants. Auth isn't involved — the scheduler
|
||
creates channels with system identity. Token budgets from v0.24.2
|
||
apply as execution budgets.
|
||
|
||
---
|
||
|
||
## Resolved Decisions
|
||
|
||
1. **OIDC client type:** Confidential with backend callback at
|
||
`/auth/oidc/callback`. The frontend never sees the authorization
|
||
code. PKCE (public client) is deferred — unnecessary for a
|
||
server-side app.
|
||
|
||
2. **Permission inheritance through teams:** No implicit team
|
||
permissions. Teams scope visibility, groups grant capabilities.
|
||
The two remain orthogonal. A team admin who wants all members to
|
||
have `channel.create` creates a group, assigns the permission,
|
||
and adds the team members.
|
||
|
||
3. **Group priority for token budgets:** Most-restrictive wins.
|
||
No "override" group type. Safe and predictable. Admins who need
|
||
exceptions create a dedicated group with a higher budget.
|
||
|
||
4. **Handle collision on auto-provision:** Append numeric suffix
|
||
(`-2`, `-3`) until unique. Same logic as persona handle generation
|
||
via `HandleFromName()`. The unique index is the backstop.
|
||
|
||
5. **Session cleanup:** Deferred. Table is append-only and low-volume
|
||
until workflows are heavily used. A cleanup CronJob (delete sessions
|
||
older than N days with no messages) is a natural addition in v0.25.0
|
||
when workflow channels are actively creating sessions.
|