Users and Tenant Membership¶
Status¶
Accepted — implementation phased; this record describes the target model and the rationale for introducing human users alongside the existing API-key model.
Context¶
SignalDB authentication today has no notion of a person. The only principals are:
- Tenant API keys — machine credentials bound to exactly one tenant,
validated by
Authenticator(config-defined keys first, then the catalog'sapi_keystable). The result of authentication is aTenantContext(tenant, dataset, slugs, key name) consumed by every downstream layer: HTTP middleware, the acceptor's gRPC interceptor, WAL pathing, Iceberg namespacing, and rate limiting. - A single admin API key — one static shared secret guarding the entire admin API, with no identity behind it.
This creates four concrete problems:
- No human identity or audit. The closest thing to "who did this" is the optional API key name. Admin actions are anonymous.
- Credential-hostile UI login. The embedded explore UI asks the user to paste a raw tenant API key, which is then stored (base64, unencrypted) in the session cookie and replayed on every request. Logout invalidates nothing; a leaked cookie is a leaked long-lived credential.
- One person, one tenant. A key binds to a single tenant, so an operator
who works across tenants juggles multiple keys manually.
whoamican only ever answer with the key's one tenant. - No roles. Within a tenant every key is equally powerful; there is no viewer/member/admin distinction.
Decision¶
Introduce users as a second kind of principal, connected to tenants through an explicit membership relation, while keeping API keys as the machine credential for ingestion.
Key architectural constraint: TenantContext is the narrow waist¶
All request handling downstream of authentication consumes TenantContext
and does not care how it was produced. Users are therefore introduced as a
second way to produce a TenantContext — the entire data plane (acceptor,
writer, querier, WAL, Iceberg layout, rate limiting) is untouched.
Data model (service catalog)¶
users (id, email UNIQUE, display_name, password_hash,
is_instance_admin, created_at, updated_at, disabled_at)
tenant_memberships (user_id, tenant_id, role IN ('admin','member','viewer'),
created_at, PRIMARY KEY (user_id, tenant_id))
user_sessions (id, token_hash UNIQUE, user_id, created_at,
expires_at, revoked_at)
Both catalog backends (SQLite and PostgreSQL) carry the same tables, following the existing inline-DDL pattern.
Email is the login identity and is case-insensitive: values are canonicalized (trimmed, lowercased) at account creation and at login, and the unique constraint applies to that canonical form. Normalizing in the application keeps identity semantics identical on both backends instead of depending on backend-specific collation behavior.
Authentication model¶
- API keys stay as the machine credential for OTLP ingestion. The gRPC
path does not change. API keys may later gain a
created_byuser column for provenance. - Users are for humans: the embedded UI, tenant self-service API, and admin API. Login is email + password; passwords are hashed with Argon2id (memory-hard KDF for low-entropy secrets). The existing unsalted SHA-256 helper remains correct for high-entropy random API keys and session tokens, and wrong for passwords.
- Server-side sessions replace the raw-key cookie. The session cookie
becomes an opaque random token whose SHA-256 hash is stored in
user_sessions, so logout and revocation actually work and no long-lived credential lives in the browser. The token is still a bearer credential, so the session contract bounds its blast radius: sessions carry a bounded absolute lifetime (expires_at) plus an idle timeout; the cookie is setHttpOnly; Secure; SameSite=Lax(the current UI cookie already shipsHttpOnlyandSameSite=Lax); a fresh token is issued on every login rather than reusing an existing one; and CSRF is mitigated bySameSite=Laxcombined with origin checks on state-changing requests.Lax, notStrict, because the session cookie is set at the end of an OIDC/OAuth redirect chain (IdP -> callback), and aStrictcookie set during that cross-site navigation is not reliably sent on the browser's very next same-origin request.SameSite=Laxstill withholds the cookie from cross-sitePOST/PUT/DELETE/PATCHrequests — the methods that actually mutate state under normal REST/HTTP semantics — so CSRF protection holds for any endpoint that correctly requires a non-GETmethod for its mutation. The attribute's exemption is narrower: a cross-site top-levelGETnavigation still carries the cookie. That is safe only as long asGEThandlers stay read-only; at least one router route breaks that assumption today —GET /ui/session/oidc/callback(the OIDC redirect target itself) JIT-provisions users, grants tenant memberships, and creates sessions, all from aGETrequest. This is the standard OAuth 2.0 authorization-code callback shape (RFC 6749 mandates a GET redirect from the IdP) and that endpoint already defends itself with its own state/nonce/PKCE checks independent of cookieSameSite, but it means the "GET never mutates" caveat above does not hold everywhere in this codebase, and any future GET handler that mutates state without equivalent protection would sit outsideSameSite=Lax's CSRF coverage. The improvement over the raw-key cookie is bounded lifetime and real server-side revocation — not immunity to cookie theft. - Disabled users are cut off immediately. A non-null
disabled_atfails both password login and session validation: session lookup joins againstusers, so disabling a user invalidates their existing sessions at the next request without requiring per-session revocation. - Tenant resolution via membership. For a user request, the requested
tenant is validated against
tenant_membershipsinstead of key ownership. This is what makes one-person-many-tenants work;whoaminaturally returns all memberships.TenantContextgains optionaluser_id/rolefields so handlers can enforce roles and audit logs name a person. - Instance admin becomes a flag on users, giving admin-API actions a real
identity. The static
admin_api_keyremains as a break-glass and automation credential. - Bootstrap via CLI (
signaldb-cli user create ... --instance-admin) and/or a config-declared initial user, mirroring tenant bootstrap.
Roles¶
Per-tenant roles start minimal:
| Role | Intent |
|---|---|
admin |
Manage the tenant: datasets, API keys, members |
member |
Read and write data, use self-service API |
viewer |
Read-only queries |
Phasing¶
- Foundation: catalog tables and CRUD; Argon2id password hashing and
session-token utilities; password login issuing session tokens; extended
whoami. No role enforcement yet (every member acts as today's key-holder). - Roles: enforcement on the tenant self-service and admin APIs; membership management endpoints (instance-admin and tenant-admin).
- Later: OIDC/SSO as an alternative credential on the same
usersrow; possibly unifying API keys as service accounts.
Consequences¶
- The ingestion path keeps its exact current behavior and performance; no agent configuration changes.
- Session validation adds a catalog lookup per UI request; a small in-memory cache with TTL bounds the cost (config-defined API keys are already served from memory today).
- The previous cookie format (base64 of the raw API key) is replaced; existing UI sessions simply re-login once.
- The
_systemself-monitoring tenant suppression in the auth middleware must keep applying to user-originated requests that resolve to the_systemtenant. - Two credential planes (keys for machines, passwords/sessions for humans) are deliberate: neither can be removed without breaking the other's use case, and each uses hashing appropriate to its entropy.