Files
hub/docs/architecture/storage/identity.md
glm-5.1 2b63cda1c7 Setup repo: migrate architecture specs, code stubs, and tasks from alkhub_ts
Copy architecture docs, ADRs, storage domain specs, research, reviews,
and 56 storage architecture tasks from the alkhub_ts monorepo. Adapt for
standalone @alkdev/hub repo structure (src/ not packages/hub/).

Sanitize all sensitive information:
- Replace private IPs (10.0.0.1) with localhost defaults
- Remove internal server hostnames (dev1, ns528096)
- Replace /workspace/ private paths with npm package references
- Remove hardcoded credentials from examples
- Rewrite infrastructure.md without private network details

Add Deno project scaffolding: deno.json (pinned deps), .gitignore,
AGENTS.md, entry point. Migrate existing code stubs (crypto, config
types, logger) with updated import paths.
2026-05-25 10:56:32 +00:00

14 KiB

status, last_updated
status last_updated
draft 2026-04-20

Table Schemas: Identity & Auth

Account, organization, and authentication tables. For cross-cutting reference (cascade behavior, index reference, status enums, relations), see table-reference.md. For design decisions, see ../../../decisions/. For the account-role-session model, see ../../agent-roles.md.

accounts

Hub-local identity records. These are NOT Gitea users — they're identities in our system. They can be linked to Gitea accounts but aren't required to be. This table is the FK target for api_keys.ownerId, audit_logs.ownerId, clients.ownerId, organizations.ownerId, and sessions.accountId.

Accounts serve as the identity layer for both humans and LLMs. An LLM that creates sessions, makes commits, or owns API keys needs its own account (typically with accessLevel: "service"). See ADR-012 for the terminology rationale.

Column Type Notes
commonCols id, metadata, createdAt, updatedAt
email text NOT NULL UNIQUE Unique identifier. System/service accounts MAY use a deployment-configured reserved email pattern (e.g., {model}@system.example.com). The reserved pattern is a deployment concern — no specific domain is hardcoded. See D6 in storage-spec-phase1-resolutions.md.
displayName text Display name
accessLevel text NOT NULL DEFAULT user admin, user, service
status text NOT NULL DEFAULT 'active' Enum: active, suspended, deactivated. See D5 in storage-spec-phase1-resolutions.md.
giteaUsername text Link to Gitea account (nullable — service/LLM accounts may or may not have one)
data jsonb Account metadata (preferences, avatar URL, etc.)

data boundaries: Account preferences and profile metadata. Authentication credentials never go here — API keys are in api_keys, secrets are in client_secrets.

Indexes: unq_accounts_email UNIQUE on (email), idx_accounts_gitea_username on (giteaUsername), idx_accounts_display_name on (displayName) — user search/autocomplete UIs.

accessLevel semantics (renamed from role to avoid confusion with behavioral roles — see ADR-012):

  • admin: Can manage all resources across organizations
  • user: Can manage own resources and resources in organizations they belong to
  • service: Automated accounts — LLM workers, spoke credentials, CI tokens. No Gitea link required.

Account lifecycle: Deactivated accounts cannot authenticate. Suspended accounts are admin-locked (e.g., security hold). Deactivated is user-initiated shutdown. Suspended/deactivated accounts can still own organizations (RESTRICT FK) and have audit entries (RESTRICT FK) but cannot authenticate.

System account email convention: Deployments may configure a reserved email domain or pattern for system-generated accounts (LLMs, bots, services). This prevents collision between human and system accounts and enables attribution in git commits and audit logs. The specific pattern is deployment-specific and should not be hardcoded in architecture documentation.

LLM accounts: An LLM worker account (e.g., with a deployment-configured system email) has accessLevel: "service". It owns sessions, API keys, and audit trail entries. The LLM fills a role (defined in the roles table or .opencode/agents/*.md) for the duration of a session. The account provides identity and accountability; the role provides behavioral constraints and permissions.

Authorization rules for accessLevel: Only admin accounts can change another account's accessLevel. Accounts cannot self-promote. service accounts cannot change accessLevel at all. user accounts cannot change accessLevel of any account. The hub operations hub.account.updateAccessLevel and hub.account.create enforce these rules at the application layer. For the admin/user/service terminology distinction (renamed from role), see ADR-012.

organizations

Top-level grouping for multi-tenancy. Organizations own projects, can scope clients, and group members. Minimal — just name + ownership. Gitea integration bridges via giteaOrgName.

ownerId semantics: This is the administrative/transferable owner of the organization. It MUST be an account that is also a member with membershipLevel: 'owner' (enforced by app logic). If the owner account needs to be changed, org.transferOwnership must be called first. RESTRICT FK prevents deleting the owner account.

Column Type Notes
commonCols id, metadata, createdAt, updatedAt
name text NOT NULL UNIQUE Organization name
slug text NOT NULL UNIQUE URL-friendly identifier
giteaOrgName text Link to Gitea organization (nullable — some orgs are hub-only)
ownerId text NOT NULL FK → accounts.id — Administrative/transferable owner of the org. RESTRICT cascade prevents deleting the owner account while the org exists.
data jsonb Org metadata (billing, settings)

Indexes: unq_organizations_name UNIQUE on (name), unq_organizations_slug UNIQUE on (slug), idx_organizations_owner_id on (ownerId), idx_organizations_gitea_org_name on (giteaOrgName).

organization_members

Who belongs to which org. Simple membership + level.

Column Type Notes
commonCols id, metadata, createdAt, updatedAt
orgId text NOT NULL FK → organizations.id (cascade)
accountId text NOT NULL FK → accounts.id (cascade)
membershipLevel text NOT NULL owner, admin, member

Unique constraint: (org_id, account_id) — one membership per account per org.

Indexes: unq_org_members_org_account UNIQUE on (orgId, accountId), idx_org_members_account_id on (accountId), idx_org_members_org_id on (orgId) — find members of an org.

membershipLevel semantics (renamed from role to avoid confusion with behavioral roles — see ADR-012): owner has full control including billing and member management. admin can manage projects and members. member can access org resources.

membershipLevel is runtime access control, separate from ownerId: membershipLevel: 'owner' grants elevated permissions within the org. This is distinct from organizations.ownerId, which is the administrative/transferable owner. The invariant is: organizations.ownerId always references an account that also has membershipLevel: 'owner' in organization_members.

Org Ownership Transfer

When an account that owns an organization needs to be removed, the organization's ownership must be transferred first (because organizations.ownerId → accounts.id has RESTRICT cascade).

The org.transferOwnership operation:

  1. Validates that the new owner is an account with membershipLevel: 'owner' in the organization
  2. Updates organizations.ownerId to the new owner
  3. Optionally demotes the old owner's membershipLevel to 'admin' or 'member'

Precondition: organizations.ownerId must always reference a member with membershipLevel: 'owner'. Transfer must happen before account deactivation or deletion of the current owner.

Error cases: If the organization has no other members with membershipLevel: 'owner', the transfer requires promoting a member first.

api_keys

API keys for hub authentication. Uses keypal (v0.1.11) for key generation, hashing, verification, and scope management. The table follows our commonCols pattern but with proper columns for high-query fields instead of keypal's default JSONB-only approach.

Column Type Notes
commonCols id, metadata, createdAt, updatedAt
ownerId text NOT NULL FK → accounts.id — Key owner (maps to keypal's ownerId)
keyHash text NOT NULL SHA-256 hash of the raw key (never stores raw key)
name text Human-readable key label
description text Key purpose description
enabled boolean NOT NULL DEFAULT true Disable without revoking
expiresAt timestamp with tz When the key expires (null = never)
revokedAt timestamp with tz When the key was revoked (null = active)
rotatedToId text ID of the key this was rotated to
lastUsedAt timestamp with tz Last time the key was used to authenticate

Indexes: idx_api_keys_owner_id on (ownerId), unq_api_keys_key_hash UNIQUE on (keyHash), idx_api_keys_enabled on (enabled) — filter enabled/disabled keys, idx_api_keys_active partial on (ownerId) WHERE revoked_at IS NULL AND enabled = true — efficiently find active keys. Note: idx_api_keys_key_hash is not listed separately because unq_api_keys_key_hash UNIQUE constraint auto-creates an index covering the same column.

Keypal integration: We implement keypal's Storage interface as a thin adapter (HubKeyStorage) that reads/writes this table. The metadata JSONB column (from commonCols) stores keypal's scope data:

  • metadata.scopes: string[] — global permission scopes
  • metadata.resources: Record<string, string[]> — resource-scoped permissions (key format: "type:id")
  • metadata.tags: string[] — filtering tags (lowercased)

This gives us proper SQL indexing on owner_id, key_hash, enabled, expires_at, revoked_at while keeping the flexible scope model in metadata.

SHA-256 trade-off: API keys are hashed with SHA-256, not a slow KDF (bcrypt, Argon2). This is acceptable because API keys are high-entropy machine-generated strings (128-bit+), making brute-force infeasible even with a fast hash. Human passwords require slow hashes; machine keys do not. This provides O(1) verification latency at high throughput. See ADR-010.

Expiration and revocation behavior:

  • expiresAt is nullable — null means the key never expires. When present, the key is rejected after expiresAt. The enabled field is a separate kill switch (immediate disable regardless of expiration). A key can be: enabled+not expired (active), enabled+expired (rejected), disabled (rejected regardless of expiration).
  • revokedAt is set when keypal.revoke() is called. Revoked keys are permanently disabled regardless of enabled/expiry status.
  • Error responses: Expired, disabled, and revoked keys all return a generic authentication failure — not a specific reason — to avoid information disclosure to attackers.

Key lifecycle:

  • Create: keys.create({ ... }) → generates raw key, hashes it, stores hash in key_hash, returns { key, record }
  • Verify: keys.verify(token) → hashes the token, looks up by key_hash, checks enabled / revoked_at / expiresAt
  • Revoke: keys.revoke(id) → sets revoked_at to now (soft delete)
  • Rotate: keys.rotate(id) → creates new key, sets rotated_to_id on old key
  • Scope check: keys.hasScope(record, scope) or keys.checkResourceScope(record, type, id, scope)

Caching: Use keypal's RedisCache with our existing Redis instance for key verification caching. Cache stores only the slim CacheRecord (id, expiresAt, revokedAt, enabled), not full metadata.

ownerId semantics: api_keys.ownerId is a FK to accounts.id. The account may be a user, admin, or service account. Service accounts (e.g., a spoke that needs its own API key) get an accounts row with accessLevel: "service". This replaces the previous opaque string model with proper referential integrity.

audit_logs

Audit trail for API key operations and security-relevant hub events.

Column Type Notes
commonCols id, metadata, createdAt, updatedAt
action text NOT NULL Action type: created, revoked, rotated, enabled, disabled, login, access_denied
keyId text FK → api_keys.id (nullable — not all audit events are key-related)
ownerId text NOT NULL FK → accounts.id — The identity that performed the action. RESTRICT cascade — accounts with audit entries cannot be hard-deleted; use account deactivation (status column) instead.
sessionId text FK → sessions.id — The session in which the action occurred. Nullable — not all actions happen in a session context. onDelete: SET NULL
orgId text FK → organizations.id — The organization context for the action. Nullable — personal actions aren't org-scoped. onDelete: SET NULL
details jsonb Action-specific context (IP, user agent, scope changes, etc.)

Indexes: idx_audit_logs_owner_id on (ownerId), idx_audit_logs_key_id on (keyId), idx_audit_logs_action on (action), idx_audit_logs_created_at on (createdAt), idx_audit_logs_session_id on (sessionId), idx_audit_logs_org_id on (orgId).

Session and org context enable filtering audit logs by session (e.g., "what did this agent session do?") and organization (e.g., "show me all actions in this org").

Keypal integration: keypal's optional audit log methods (saveLog, findLogs, countLogs) are implemented on HubKeyStorage to write to this table. Hub-native audit events (login, access denied) also write here.

action enum is extensible: The initial set of action types (created, revoked, rotated, enabled, disabled, login, access_denied) covers keypal key operations and basic auth events. Additional actions for account, membership, and organization lifecycle events (e.g., account_created, membership_added, org_created) should be added as those features are implemented. New action types must be documented here and in table-reference.md.