Spec: Users, Roles, and Project Grants
- Status: Draft
- Last amended: 2026-06-11
- Constrained by: ADR-0005, ADR-0007, ADR-0010, ADR-0017, ADR-0018, ADR-0036
- Implements:
packages/daemon/(identity, session, gate, account, user-admin routes),packages/storage/(user tables) - Supersedes:
guests.md(renamed and rewritten here per ADR-0036 Phase 0)
Purpose
This spec defines the unified user principal system. kaged no longer forks identity into "operators" and "guests"; it has one users table with a three-tier role (operator > member > guest), one storage-backed session mechanism, and one cookie. Credentials (password, SSO) are columns, not subtypes. Unknown SSO subjects are provisioned trust-on-first-use (TOFU) as pending rows and activated by an operator through already-trusted auth.
This document is normative for:
- The SQLite/Postgres schema for
users,user_invites,user_sessions,project_user_grants. - The role model (
operator/member/guest) and what each tier may do. - The credentials-as-columns model and the available login methods per row.
- The handle derivation rules and display-name precedence.
- The avatar fetch/store/serve rules.
- The TOFU provisioning and activation lifecycle, including pending reaping.
- The single
kaged_user_sessioncookie and session validation. - The
--insecureinteraction with user sessions. - The Zod schemas and TypeScript types for these entities.
It is not normative for:
- The ambient operator auth (loopback nonce, sidecar headers) — that's
daemon.mdandhttp-api.md. - The route-scope authorization gate (
accesstaxonomy,PROJECT_RESOLVERS) — that'shttp-api.md. - The SSO relay and token format — that's
sso-relay.md. - The detailed HTTP request/response payloads — that's
http-api.md. - The UI layout — that's
ui/README.md.
Constraints (from ADRs)
| Constraint | Source |
|---|---|
| Default storage is SQLite at a file path; Postgres opt-in via URL | ADR-0005 |
| IDs are ULID TEXT; timestamps are INTEGER epoch-ms; CHECK lives in CREATE | ADR-0005 |
| Auth identity threads through sessions for audit and multi-operator | ADR-0007 |
| Daemon verifies SSO token signatures only at session bootstrap; implements no OIDC flow | ADR-0007 §amended by ADR-0036 §9 |
| Two deployment modes: per-user and system-wide, both first-class | ADR-0010 |
| Guests are first-class identities with daemon-managed credentials | ADR-0017 |
| Project access is granted via per-project permission sets, not project DSL | ADR-0018 |
Unified users table; three-tier role; one session; route-scope authz |
ADR-0036 |
Database Schema
Four tables back the unified user system, managed by @kaged/storage. This DDL is the contract (ADR-0036 §4.2). Timestamps are INTEGER epoch-ms (ADR-0005). The primary key is an opaque ULID — never the SSO subject.
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, -- ULID, daemon-generated. NEVER the sub.
handle TEXT UNIQUE NOT NULL, -- url-safe slug; see "Handle derivation"
display_name TEXT, -- precedence: user-edited > token name > handle
email TEXT, -- from token or operator entry; display only
avatar_path TEXT, -- relative path under {data_dir}/avatars/; see "Avatars"
provider_sub TEXT, -- provider-prefixed sub; presence ⇒ SSO credential
password_hash TEXT, -- presence ⇒ password credential
role TEXT NOT NULL DEFAULT 'guest'
CHECK (role IN ('operator', 'member', 'guest')),
status TEXT NOT NULL
CHECK (status IN ('pending', 'active', 'disabled')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_sub
ON users(provider_sub) WHERE provider_sub IS NOT NULL;
CREATE TABLE IF NOT EXISTS user_invites (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS project_user_grants (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permission_set TEXT NOT NULL,
notes TEXT,
granted_at INTEGER NOT NULL,
granted_by TEXT NOT NULL,
last_modified_at INTEGER NOT NULL,
PRIMARY KEY (project_id, user_id),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_invites_user ON user_invites(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_project_user_grants_user ON project_user_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status, created_at);
Migration from the guest schema (v14 → v15)
The pre-ADR-0036 schema had guests, guest_invites, guest_sessions, project_guest_grants (schema version 6, schema-version 14 overall). The unified schema is version 15. Because SQLite cannot ALTER TABLE … ADD CHECK, the tables are rebuilt in a single transaction (PRAGMA foreign_keys = OFF for its duration). The exact procedure is ADR-0036 §10 Phase 1:
- Create
users, copy everyguestsrow withrole = 'guest',email/avatar_path/provider_sub = NULL,status/user_id/handle/display_name/password_hash/created_at/updated_atpreserved. - Create
user_invites, copyguest_invitesverbatim. - Create
user_sessionsempty — live guest sessions are intentionally invalidated; guests re-login once. - Create
project_user_grants, copyproject_guest_grantsverbatim. - Drop the four guest tables (children before parent).
- Create all indexes;
PRAGMA foreign_key_checkmust return empty.
Invariant: every migrated row has role = 'guest', status preserved, user_id preserved (so historical created_by stamps keep resolving). Postgres takes the same logical steps using ALTER/RENAME + add-CHECK, which Postgres supports.
Note on the historical user_id prefix. Pre-migration guest ids carried a
guest:<ULID>prefix (ADR-0017). Those ids are preserved verbatim during migration so auditcreated_bystamps keep resolving; theguest:prefix on legacy rows is data, not a type discriminant. New users (TOFU or operator-created) get a bare ULIDuser_id. The daemon treatsuser_idas opaque in both cases.
The role model
operator |
member |
guest |
|
|---|---|---|---|
| System settings, providers, LLM keys, plugins, daemon config | ✅ | ❌ | ❌ |
| Add/load/delete projects | ✅ | ❌ | ❌ |
| User administration, grants, activation | ✅ | ❌ | ❌ |
| Global logs, global audit, spend ledger | ✅ | ❌ | ❌ |
| Operator UI shell | ✅ all projects | ✅ granted projects only | ❌ |
| Sessions/runs/checkpoints/issues/workflows in granted projects | ✅ (all, grant bypass) | ✅ | per guest permission_set, /g/ surface |
/g/ realm |
✅ | ✅ | ✅ (granted projects) |
| Account surface (own profile) | ✅ | ✅ | ✅ |
- Roles are strictly ordered:
operator ⊇ member ⊇ guest. A higher role can do everything a lower role can. An operator visiting/g/is themselves — not a second principal. memberexists for exactly one reason in v0: operator-interface access without system access. It does not provide isolation between operators (two operators/members can still read/resume/delete each other's sessions in granted projects —created_byis attribution, not a security boundary; ACL is a later ADR), fine-grained per-project permissions, or any change to subagent/sandbox semantics.permission_setfor members: exactly one value exists —"member"— meaning full project access on the operator surface. The fine-grained matrix is deferred (ADR-0036 §6). Guest permission sets are unchanged (see "Project grants" below).- Ambient identities are NOT rows. Loopback and sidecar identities remain outside the
userstable; they are bootstrap/break-glass authorities and always resolve torole = "operator". As a result,created_byhistory permanently contains two namespaces — ambient usernames (e.g.operator) and table ULIDs (01HX…). This is accepted and documented; the UI name-resolution helper (ui/README.md) renders both.
Credentials are columns, not types
A user's available login methods are readable from the row:
provider_sub IS NOT NULL⇒ SSO login works.password_hash IS NOT NULL⇒ password login works (the invite flow below).- Both set is legal (a member with SSO plus a password fallback) — no migration needed later.
- Both NULL is legal only while
status = 'pending'with an outstanding invite.
The primary key is an opaque ULID, never the sub. The sub is a credential, not an identity (ADR-0036 Alternative D).
Password hashing and rules
Passwords are hashed with Argon2id via Bun's native API (Bun.password.hash):
- Memory cost 64 MiB (m=65536), time cost 3 (t=3), parallelism 1 (p=1).
- Minimum 8 characters. No complexity rules.
Concurrent hashing is bounded by a queue to avoid CPU/memory exhaustion; a full queue returns 503 unavailable.
Handle derivation
- Default (SSO): slugified local-part of the email (
bob.smith+kaged@gmail.com→bob-smith-kaged): lowercase,[a-z0-9-]only, runs of other chars collapse to a single-, trim leading/trailing-, max 32 chars. - Collision: append
-2,-3, … (first free integer). - No email (operator-created password user): the operator supplies the handle (as the guest flow does today). Operator-supplied handles must match
^[a-z0-9_-]{3,32}$. - User-editable afterwards via the account surface; uniqueness enforced. Handles are display/mention keys. The only login path that consumes a handle is the existing password login, which accepts the handle as the username.
Display-name precedence
display_name set by the user (or operator) always wins over the token's name claim. The token name populates the field at creation only; subsequent logins never overwrite it.
Avatars: fetch, never hotlink
If an SSO token carries picture, the daemon fetches it once at row creation:
- Timeout 5 s, max 1 MiB, content-type must be
image/png|jpeg|webp|gif. - Stored at
{data_dir}/avatars/{user_id}.{ext};avatar_pathrecords the relative path. - Fetch failure is non-fatal: the row is created with
avatar_path = NULL; the UI falls back to initials.
Avatars are served only from GET /api/v1/users/:uid/avatar (scope account — any active user may render any avatar). The daemon never renders the provider's CDN URL directly (that would ping Google/GitHub from every viewer's browser — telemetry-shaped exhaust, forbidden). Users may upload a replacement via the account surface (same constraints as fetch).
TOFU provisioning and activation lifecycle
unknown sub arrives via POST /api/v1/auth/sso
├─ user_creation = "disabled" → 403 forbidden, details.reason: "user_creation_disabled".
│ NO row is created. No pending-row side channel.
└─ user_creation = "enabled" → create row:
role = 'guest' (the floor — entry point is irrelevant to the row),
status = 'pending',
handle/display_name/email/avatar from token,
provider_sub = token sub.
→ 403 forbidden, details.reason: "user_pending".
→ UI shows "awaiting activation" in whichever realm they entered from.
known sub, status = 'pending' → 403 "user_pending" (no row changes).
known sub, status = 'disabled' → 403 "user_disabled".
known sub, status = 'active' → mint user session.
- Activation happens through already-trusted auth only: an active operator session, or the ambient loopback/sidecar identity.
PATCH /api/v1/users/:uidsetsstatus = 'active'and optionallyrole. There is no auto-trust of the first SSO user. The bootstrap chain: ambient operator (launch URL / sidecar) activates and elevates the first SSO operator; everything after can be done by that operator from the UI. - Pending reaping: rows with
status = 'pending'ANDprovider_sub IS NOT NULLAND no invite token, older thanauth.sharedsso.pending_ttl_days(default 7), are deleted by the daily reaper that already handles invite tokens. Pending rows created via the invite flow are governed by their invite TTL, unchanged. user_creationis a toggle so an operator can open registration, add who they want, then close it. Default"disabled".
Invite and password management (password credential)
Unchanged in behavior from the guest flow; now reads/writes the users/user_invites/user_sessions tables and sets the new cookie.
Invite flow
- Operator creates a user record (status
pending, nullpassword_hash). - Daemon generates a one-time invite token (32-byte hex), stores it in
user_invites, returns{ui_origin}/g/setup?token={token}. - Operator distributes the URL out-of-band.
- Guest visits the setup URL, sets a password, submits.
- Daemon validates the token, hashes the password, sets
status = active, deletes the invite token.
Invite token TTL
Default 7 days, operator-overridable. Expired tokens reaped daily.
Reinvite (password recovery)
No self-service reset. POST /api/v1/users/:uid/reinvite (operator): invalidates all sessions, sets status = pending, clears password_hash, mints a new invite token, returns the setup URL.
Password change / first-password
POST /api/v1/account/password (account scope): for a user that already has a password, current-password verification is required; setting a first password on an SSO-only account requires no current password but requires an active session. On success all other sessions for that user are invalidated.
Session management
One cookie
Sessions are tracked with a single cookie kaged_user_session — HttpOnly, SameSite=Lax, Path=/, Secure when the daemon believes it is served over TLS (existing heuristic). This replaces kaged_guest_session. SSO-bootstrapped and password-bootstrapped sessions are rows in the same user_sessions table.
- TTL: same as the old guest sessions (default 30 days); sliding
last_active_at, unchanged semantics. - Ambient operator mechanisms are untouched:
kaged_session(loopback nonce) and the sidecar header contract continue to resolve to the ambient operator identity, outside theuserstable. - The old "a browser holding both cookies is two distinct principals" property is removed. With unified identity, an operator viewing the guest surface is legitimately themselves. A view-as/impersonation affordance is future work, noted, not built.
- At migration all
kaged_guest_sessionsessions are invalidated (the table is rebuilt empty). Guests re-login once. Acceptable: sessions are ephemeral.
Validation
Every request carrying kaged_user_session is validated against user_sessions: session must exist and not be expired; the referenced user must exist and have status = 'active' (else the gate falls through — disabled/pending users cannot ride a stale session). last_active_at is updated on every valid request.
--insecure interaction
insecure waives the ambient operator check only: a request with no user session resolves to the synthesized operator identity. A request carrying a valid kaged_user_session still resolves as that user with that user's role, and user credentials (passwords, SSO tokens) are still verified at login. Insecure means "I vouch for whoever reaches this port as me," not "everyone is everyone." (Generalizes and replaces the old guest rule.)
Project grants
Project access is governed by project_user_grants (operator-local, not in the portable DSL).
permission_set
The permission_set column stores a JSON blob, Zod-validated. Two shapes exist:
- Member grant: the literal string
"member"— full project access on the operator surface. - Guest grant: the structured object (unchanged from the guest spec):
{
"workflows": ["testimonial.add", "blog.draft"],
"issues": { "file": true, "view_own": true, "view_all": false, "comment_own": true },
"session": { "view_own_history": true }
}
Grant CRUD and revocation
- Operator-only in v0: only operators create/read/update/delete grants. (Grant admin endpoints are
systemscope — seehttp-api.md.) - Revocation deletes the grant row; project access terminates immediately. Active sessions are not destroyed, but subsequent project-scoped requests are rejected by the gate.
Composition with the DSL
The workflows array references workflow names in the project's DSL workflows: block. Stale references are flagged at read time, rejected with workflow_not_found at invocation, and warned at project load (unchanged).
Audit events
Every user and grant lifecycle action is recorded in the audit log, tagged with the performing principal's id (ambient username or table ULID).
User lifecycle events
user.created— operator created a record (invite flow) or TOFU provisioned a pending SSO row.user.invited— a one-time invite token was generated (records token prefix only).user.activated— a pending user was activated (setactive); records the activating principal and the assigned role.user.role_changed— an operator changed a user's role.user.login— a successful login (records credential class:password|sharedsso).user.login_failure— a failed login attempt.user.locked— account locked due to repeated failures.user.password_changed— a user changed/set their password.user.disabled— operator disabled a user.user.sso_provisioned— TOFU created a pending row from an SSO token (subset ofuser.created; emitted withproviderandsubprefix).
Grant lifecycle events
grant.created,grant.modified,grant.revoked— unchanged.
API endpoint summary
Detailed payloads live in http-api.md; route-scope (access) classification lives there too. This is the high-level inventory.
Account surface (account scope — all active users)
GET /api/v1/me— current principal incl.roleand profile fields.GET/PATCH /api/v1/account—display_name,handle(uniqueness), avatar upload.POST /api/v1/account/password— change/set password.POST /api/v1/auth/logout— delete the caller's session, clear the cookie. (/api/v1/g/logoutaliases for one release.)GET /api/v1/users/:uid/avatar— render any active user's avatar.GET /api/v1/users/lookup?ids=…— batchedcreated_byresolution; returns only{user_id, handle, display_name, has_avatar}. No emails, roles, or subs.
User administration (system scope — operators only)
Renames /api/v1/guests* → /api/v1/users*:
GET /api/v1/users— list (gainsrole,provider_subpresence badge,statusfilter incl.pending).POST /api/v1/users— create a password user (invite flow), returns invite URL.GET /api/v1/users/:uid— details.PATCH /api/v1/users/:uid— update handle/display_name/status/role (activation + elevation in one call).DELETE /api/v1/users/:uid— delete, cascading.POST /api/v1/users/:uid/reinvite— invalidate sessions + new invite URL.POST /api/v1/users/:uid/unlock— unlock.GET /api/v1/users/:uid/grants— project grants enriched with project metadata.GET /api/v1/users/:uid/activity— chronological activity feed (optionalproject_idfilter).
Grant management (system scope in v0 — operators only)
GET/POST /api/v1/projects/:slug/users— list/create grants.PUT/DELETE /api/v1/projects/:slug/users/:uid— replace/revoke a grant.
Public auth (public scope)
GET /api/v1/auth/methods— available login methods (seehttp-api.md).POST /api/v1/auth/sso— verify an SSO token, run the TOFU lifecycle, mint a session.GET /api/v1/g/setup/validate,POST /api/v1/g/setup,POST /api/v1/g/login— unchanged behavior; new cookie name.
Guest-realm (guest-realm scope — any active user; project entries also require a grant)
- All
/api/v1/g/*except the public setup/login endpoints.
Type Definitions
import { z } from "zod";
export const RoleSchema = z.enum(["operator", "member", "guest"]);
export type Role = z.infer<typeof RoleSchema>;
export const UserStatusSchema = z.enum(["pending", "active", "disabled"]);
export type UserStatus = z.infer<typeof UserStatusSchema>;
// Guest permission set (structured)
export const GuestPermissionSetSchema = z.object({
workflows: z.array(z.string()),
issues: z.object({
file: z.boolean(),
view_own: z.boolean(),
view_all: z.boolean(),
comment_own: z.boolean(),
}),
session: z.object({ view_own_history: z.boolean() }),
});
export type GuestPermissionSet = z.infer<typeof GuestPermissionSetSchema>;
// permission_set is either the literal "member" or a structured guest set.
export const PermissionSetSchema = z.union([
z.literal("member"),
GuestPermissionSetSchema,
]);
export type PermissionSet = z.infer<typeof PermissionSetSchema>;
export const UserRecordSchema = z.object({
user_id: z.string(), // opaque ULID (legacy rows may carry a guest: prefix)
handle: z.string().regex(/^[a-z0-9_-]{3,32}$/),
display_name: z.string().nullable(),
email: z.string().nullable(),
avatar_path: z.string().nullable(),
provider_sub: z.string().nullable(),
password_hash: z.string().nullable(),
role: RoleSchema,
status: UserStatusSchema,
created_at: z.number().int(),
updated_at: z.number().int(),
});
export type UserRecord = z.infer<typeof UserRecordSchema>;
export const UserInviteSchema = z.object({
token: z.string(),
user_id: z.string(),
expires_at: z.number().int(),
created_at: z.number().int(),
});
export type UserInvite = z.infer<typeof UserInviteSchema>;
export const UserSessionSchema = z.object({
session_id: z.string(),
user_id: z.string(),
expires_at: z.number().int(),
created_at: z.number().int(),
last_active_at: z.number().int(),
});
export type UserSession = z.infer<typeof UserSessionSchema>;
export const ProjectUserGrantSchema = z.object({
project_id: z.string(),
user_id: z.string(),
permission_set: PermissionSetSchema,
notes: z.string().optional(),
granted_at: z.number().int(),
granted_by: z.string(),
last_modified_at: z.number().int(),
});
export type ProjectUserGrant = z.infer<typeof ProjectUserGrantSchema>;
// The resolved principal seen past the auth gate.
export const PrincipalSchema = z.object({
user_id: z.string(),
handle: z.string(),
display_name: z.string().nullable(),
role: RoleSchema,
});
export type Principal = z.infer<typeof PrincipalSchema>;
Failure modes
- Duplicate handle →
409 conflict. - Foreign-key cascade lock → transaction rolled back, deletion retried.
- Argon2id exhaustion →
503 unavailablewhen the hash queue is full. - Stale workflow reference →
404 not_found(workflow_not_found) at guest invocation; warned at load. - Avatar fetch failure → non-fatal;
avatar_path = NULL, UI uses initials. - Reaper failure → expired invites/pending rows linger but are still rejected on use; failure logged.
Testing notes
Unit
- Zod schemas accept valid and reject invalid shapes (handles, role/status enums, permission set union).
- Handle derivation: slugging, truncation to 32, collision suffixing.
- Display-name precedence: token name fills only on creation; never overwrites a user-set value.
- Password hashing/verification with the specified Argon2id parameters.
Integration
- Migration: a v14 fixture seeded with guests/invites/grants/sessions migrates to v15; row counts and field values verified;
foreign_key_checkempty;user_sessionsempty post-migration; every migrated rowrole = 'guest', status/user_id preserved. - Session resolution per role: operator/member/guest principals resolve correctly; disabled/pending users cannot ride a stale session.
--insecurewith a user session resolves as the user, not the ambient operator.- Password flows (invite/setup/login/reinvite/unlock/change) green against
userstables. - TOFU lifecycle: every state in the table above;
user_creation = "disabled"+ unknown sub writes nothing; toggle flips behavior without restart. - Avatar fetch stores under
{data_dir}/avatars/; oversized/wrong-content-type →avatar_path = NULL. - Grant scoping: a member without a grant cannot observe an ungranted project's existence anywhere (see
http-api.mdgate matrix).
Open questions
- Invite TTL default — 7 days; may tune.
- Member
permission_setevolution — when the fine-grained matrix lands (later ADR), the single"member"literal becomes one preset; the gate check absorbs it without architecture change. - Multiple SSO issuers per daemon — out of scope (ADR-0036 §10).
References
- ADR-0005, ADR-0007, ADR-0010, ADR-0017, ADR-0018, ADR-0021, ADR-0036
http-api.md— gate, route-scope taxonomy, payloadssso-relay.md— token format, relay contractui/README.md— role routing, name-resolution helper- Bun Password Hashing API: https://bun.sh/docs/api/hashing#bun-password
Amendments
2026-06-11 — Unified user identity (ADR-0036)
Renamed and rewritten from guests.md. Guests, members, and operators unify into one users table with a three-tier role; credentials become columns (password_hash, provider_sub); a single user_sessions table and kaged_user_session cookie replace the guest equivalents; TOFU provisioning + operator activation lifecycle added; avatars fetched-and-stored; project_guest_grants → project_user_grants with the "member" permission-set literal. The dual-cookie two-principals property is removed. The original guest activity-feed and grant-listing endpoints (2026-06-08) are preserved under the renamed /api/v1/users/:uid/{grants,activity} paths.