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.
156 lines
14 KiB
Markdown
156 lines
14 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 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](./table-reference.md). For design decisions, see [../../../decisions/](../../../decisions/). For the account-role-session model, see [../../agent-roles.md](../../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](../../../decisions/ADR-012-agent-vs-role-vs-account.md) 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](../../../decisions/ADR-012-agent-vs-role-vs-account.md)):
|
|
- `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](../../../decisions/ADR-012-agent-vs-role-vs-account.md).
|
|
|
|
### `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](../../../decisions/ADR-012-agent-vs-role-vs-account.md)): `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. |