--- 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` — 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.