Add a credential kind
This guide covers the complete checklist for adding a new credential kind backed by
the Postgres AES-GCM envelope — the same pattern used by api_key_refs,
git_credentials, claude_oauth_tokens, tracker_oauth_credentials, and env_vars.
If the secret material lives in OpenBao (a vault path or KV2 reference), it is an
OpenBao-ref kind (resources, mailbox_credentials). That kind is out of scope
here — see services::secret_scope::kind_backend and ADR 0038 D6.
Background: the three-tier model
Section titled “Background: the three-tier model”Every Postgres-envelope credential row belongs to exactly one scope tier, enforced by
an XOR CHECK (migration 156) and encoded in CredentialScope
(persistence/db/models/credential_scope.rs):
| Scope | workspace_id |
organization_id |
Wins when |
|---|---|---|---|
| Personal | NULL | NULL | most-specific by default |
| Workspace | set | NULL | overrides org |
| Organization | NULL | set | default-last; wins if locked = true |
The resolution rule from services::secret_scope::resolve_uniform:
if org[name].locked → org wins (org-shared-wins short-circuit)else → personal > workspace > org (most-specific-wins)Resolution happens in SQL via ORDER BY CASE … LIMIT 1 — the database returns
the single winner, never a full candidate list.
The locked flag (migration 164) is meaningful only on org rows. An org credential
with locked = true overrides any workspace or personal credential of the same logical
key, enabling billing/policy enforcement use-cases without a per-kind precedence rule.
-
Write the migration.
Scan the current highest migration number first — the namespace is shared across parallel developers and worktrees. See Add a database migration for the lookup recipe.
The table must include the three scope columns, the XOR CHECK, the
lockedflag, and org-aware RLS policies. Useapi_key_refs+ migrations156and164as the template:CREATE TABLE my_credentials (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,locked BOOLEAN NOT NULL DEFAULT false,secret_data TEXT NOT NULL, -- will hold scenc:v1: ciphertext-- ... your kind-specific columns ...created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),CONSTRAINT my_credentials_scope_xor_team_orgCHECK (workspace_id IS NULL OR organization_id IS NULL));-- Index for org-scoped resolutionCREATE INDEX idx_my_credentials_orgON my_credentials (organization_id)WHERE organization_id IS NOT NULL;-- RLS: read = any member; mutate = workspace admin OR org owner/adminALTER TABLE my_credentials ENABLE ROW LEVEL SECURITY;CREATE POLICY my_credentials_select ON my_credentials FOR SELECT USING (user_id = (select auth.uid())OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = (select auth.uid()))OR organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = (select auth.uid())));CREATE POLICY my_credentials_insert ON my_credentials FOR INSERT WITH CHECK ((organization_id IS NULL AND user_id = (select auth.uid()))OR organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin')));CREATE POLICY my_credentials_update ON my_credentials FOR UPDATE USING (user_id = (select auth.uid())OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin'))OR organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin')));CREATE POLICY my_credentials_delete ON my_credentials FOR DELETE USING (user_id = (select auth.uid())OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin'))OR organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin'))); -
Register the backend in
secret_scope::kind_backend.Open
server/src/services/secret_scope.rsand add your table name to thePostgresEnvelopearm:"api_key_refs"| "env_vars"| "git_credentials"| "claude_oauth_tokens"| "tracker_oauth_credentials"| "my_credentials" // add this=> Some(SecretBackend::PostgresEnvelope),The
kind_backendtests will catch an unregistered or misclassified kind. -
Add the query layer with scope-aware AAD.
Create
server/src/persistence/db/queries/my_credentials.rs. Choose a table-qualified AAD base string —"my_credentials.secret_data"— and use theprotect_secret/reveal_secrethelpers fromqueries::credential_secret:const SECRET_DATA_AAD: &str = "my_credentials.secret_data";pub async fn insert_my_credential(pool: &PgPool,user_id: Uuid,secret: &str,scope: CredentialScope,) -> sqlx::Result<MyCredential> {let (workspace_id, org_id) = (scope.workspace_id(), scope.organization_id());let protected = protect_secret(secret, SECRET_DATA_AAD, workspace_id, org_id)?;sqlx::query_as::<_, MyCredential>("INSERT INTO my_credentials (user_id, secret_data, workspace_id, organization_id)VALUES ($1, $2, $3, $4) RETURNING *",).bind(user_id).bind(protected).bind(workspace_id).bind(org_id).fetch_one(pool).await// reveal on the way out:.and_then(reveal_my_credential)}fn reveal_my_credential(mut row: MyCredential) -> sqlx::Result<MyCredential> {row.secret_data = reveal_secret(&row.secret_data,SECRET_DATA_AAD,row.workspace_id,row.organization_id,)?;Ok(row)}The
scoped_aadfunction inapp/db_secrets.rsderives the AAD as:- workspace row:
my_credentials.secret_data.workspace.<workspace_id> - org row:
my_credentials.secret_data.organization.<org_id> - personal row:
my_credentials.secret_data.user
AES-GCM authentication will fail if a ciphertext is moved to a row with a different tenant id — this is the cross-tenant relocation guard from ADR 0038 D7.
- workspace row:
-
Write the single-winner resolver.
For point-in-time resolution (fetch one credential by logical key), mirror the
ORDER BY CASE … LIMIT 1pattern. The ranks come fromsecret_scope::case_rank:SELECT *FROM my_credentialsWHERE user_id = $user -- personal candidatesOR workspace_id = $wsOR organization_id = $orgORDER BYCASEWHEN organization_id IS NOT NULL AND locked THEN 0WHEN user_id = $user AND workspace_id IS NULL AND organization_id IS NULL THEN 1WHEN workspace_id IS NOT NULL THEN 2ELSE 3 -- unlocked orgENDLIMIT 1The
secret_scopeunit tests assert thatresolve_uniformandcase_rankagree, so any divergence between this SQL and the spec is caught in CI. -
Gate writes by scope authority.
- Personal / workspace rows: any authenticated user may write to their own
personal rows; workspace rows require
WorkspaceAction::ManageCredentials(or workspace admin role). - Organization rows: require
OrgAction::ManageCredentials(orgowneroradmin). The management-API state-apply writer must stay workspace-clamped — it must never write an org-scoped row (ADR 0038 D3 anti-contamination). lockedtoggle: org rows only; require org-admin authority. Add a query equivalent toset_api_key_lockedinqueries/credentials.rs.
- Personal / workspace rows: any authenticated user may write to their own
personal rows; workspace rows require
-
Verify.
Terminal window cd server && cargo checkcargo test archcargo test secret_scopecargo test archenforces the 500-line source-file cap and module-boundary rules.cargo test secret_scoperuns theresolve_uniform/case_rankagreement test.
See also
Section titled “See also”server/src/services/secret_scope.rs— canonical resolver specserver/src/app/db_secrets.rs—scoped_aad,protect_scoped,reveal_scopedserver/src/persistence/db/models/credential_scope.rs—CredentialScopeenumserver/migrations/156_credential_org_scope.sql— XOR CHECK templateserver/migrations/164_credential_locked_and_org_rls.sql—locked+ RLS template- ADR 0038 — uniform three-tier scope rationale
- Add a database migration