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_session cookie and session validation.
  • The --insecure interaction 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.md and http-api.md.
  • The route-scope authorization gate (access taxonomy, PROJECT_RESOLVERS) — that's http-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:

  1. Create users, copy every guests row with role = 'guest', email/avatar_path/provider_sub = NULL, status/user_id/handle/display_name/password_hash/created_at/updated_at preserved.
  2. Create user_invites, copy guest_invites verbatim.
  3. Create user_sessions empty — live guest sessions are intentionally invalidated; guests re-login once.
  4. Create project_user_grants, copy project_guest_grants verbatim.
  5. Drop the four guest tables (children before parent).
  6. Create all indexes; PRAGMA foreign_key_check must 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 audit created_by stamps keep resolving; the guest: prefix on legacy rows is data, not a type discriminant. New users (TOFU or operator-created) get a bare ULID user_id. The daemon treats user_id as 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.
  • member exists 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_by is attribution, not a security boundary; ACL is a later ADR), fine-grained per-project permissions, or any change to subagent/sandbox semantics.
  • permission_set for 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 users table; they are bootstrap/break-glass authorities and always resolve to role = "operator". As a result, created_by history 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.combob-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_path records 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/:uid sets status = 'active' and optionally role. 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' AND provider_sub IS NOT NULL AND no invite token, older than auth.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_creation is 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

  1. Operator creates a user record (status pending, null password_hash).
  2. Daemon generates a one-time invite token (32-byte hex), stores it in user_invites, returns {ui_origin}/g/setup?token={token}.
  3. Operator distributes the URL out-of-band.
  4. Guest visits the setup URL, sets a password, submits.
  5. 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_sessionHttpOnly, 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 the users table.
  • 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_session sessions 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 system scope — see http-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 (set active); 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 of user.created; emitted with provider and sub prefix).

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. role and profile fields.
  • GET/PATCH /api/v1/accountdisplay_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/logout aliases for one release.)
  • GET /api/v1/users/:uid/avatar — render any active user's avatar.
  • GET /api/v1/users/lookup?ids=… — batched created_by resolution; 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 (gains role, provider_sub presence badge, status filter 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 (optional project_id filter).

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 (see http-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 handle409 conflict.
  • Foreign-key cascade lock → transaction rolled back, deletion retried.
  • Argon2id exhaustion503 unavailable when the hash queue is full.
  • Stale workflow reference404 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_check empty; user_sessions empty post-migration; every migrated row role = 'guest', status/user_id preserved.
  • Session resolution per role: operator/member/guest principals resolve correctly; disabled/pending users cannot ride a stale session.
  • --insecure with a user session resolves as the user, not the ambient operator.
  • Password flows (invite/setup/login/reinvite/unlock/change) green against users tables.
  • 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.md gate matrix).

Open questions

  1. Invite TTL default — 7 days; may tune.
  2. Member permission_set evolution — when the fine-grained matrix lands (later ADR), the single "member" literal becomes one preset; the gate check absorbs it without architecture change.
  3. Multiple SSO issuers per daemon — out of scope (ADR-0036 §10).

References


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