feat/one-api
needs attentionviewing older commit2c248b6 · incrementalpre-PRreviewed 2026-07-27 23:23 UTC8H · 12M · 8L · 5I- Purpose
- Establish a one-org-one-issuer model for the public API v1 — only a designated master key may mint or rotate child keys
- Goal
- Ship complete /v1/api-keys surface with is_master hierarchy enforced DB-side and in the decision layer, atomic cascade revoke, and auto-provisioned master at org creation
- Sub-goals
- SG-1: ServiceKey entity merged into api_keys table with kind discriminator
- SG-2: Platform service actor JWT + AuthContext gate
- SG-3: Internal mount dual-accepts service tokens
- SG-4: API-first key management — /v1/api-keys CRUD + cascade revoke
- SG-5: Round-2/3 review hardening (privilege escalation, rotation laundering, never-throw contract)
- SG-6: Master API key — one org, one issuer (this commit)
- What
- Added api-key.master.ts with prepareMasterApiKey + insertMasterApiKey; wired both org creation shells to auto-provision master atomically; added is_master column + partial unique index + migration 0067; updated decisions/errors/queries/shells for master-only minting enforcement; refreshed handler, mapper, and integration tests.
- Why
- Enforce a clear privilege hierarchy where only a designated organizational master key may issue or revoke child keys, closing the previously flat key-minting surface.
- Areas
- domains/core+2309−144apps/platform+1856−39packages/api+656−43packages/database+124−5docs/security+309−0.claude/rules+114−8.github/workflows+74−2
- Blast
- 62 files, +5511/-259 lines across domains/core, apps/platform, packages/api, packages/database (excluding 40k-line snapshot JSON)
Findings · 29
correctness4
Master key plaintext silently dropped from org-creation response
apps/platform/src/api/handlers/organizations.handler.ts
Both org-creation handlers drop result.value.masterApiKey. The secret is never stored (only scrypt hash), so the provisioned master key becomes permanently inaccessible via machine path until dashboard rotation.
revokeAllInOrg two-phase SELECT+UPDATE non-atomic — race window for in-flight creates
domains/core/src/api-key/api-key.queries.ts
SELECT snapshots live keys then UPDATE by id. A concurrent create between the two statements escapes the sweep. Advisory lock not held in revoke path. Fix: single UPDATE...WHERE...RETURNING or acquire lock.
decideRotate master check === false does not guard undefined; comment misleading
domains/core/src/api-key/api-key.decisions.ts:324
Intentional for sessions but comment implies all non-master callers are blocked. Document the three-state intent: undefined/null = session, false = non-master machine, true = master.
Orphaned JSDoc block for revokeDescendants sits above revokeAllInOrg
domains/core/src/api-key/api-key.queries.ts:234
Inserting revokeAllInOrg displaced the revokeDescendants comment. Two back-to-back JSDoc blocks confuse readers.
security3
No backfill migration — existing orgs lose machine key-to-key minting
packages/database/drizzle/0067_premium_terrax.sql
Migration sets is_master=false for all existing rows. Existing orgs have no master key post-migration. Any customer using machine key-to-key minting via /v1/api-keys will receive 403 not_master with no upgrade path. Silent breaking change.
Error message for non-master reveals master-key architecture in 403 body
domains/core/src/api-key/api-key.errors.ts:246
notMasterKey() error message names the master key tier and suggests the dashboard. Verify public mapper does not include the message field in the external response body.
Partial unique index prevents second active master but not two concurrent rotating masters — by design
OCC version check serializes concurrent rotations. No multi-active-master path is reachable.
conventions5
granterIsMaster sourcing asymmetry — state-sourced for create, command-sourced for rotate
apps/platform/src/api/handlers/public-v1/api-keys.handler.ts:223
If decideCreate is refactored to read command.granterIsMaster (mirroring rotate), master-only enforcement silently stops because the create handler never sets that field. Document or unify.
api-key.master.ts is not a canonical entity slot
domains/core/src/api-key/api-key.master.ts
Canonical form: .type .decisions .errors .queries .shells .mapper index. Move prepareMasterApiKey to .queries.ts, insertMasterApiKey to .shells.ts or cross-domain.
insertMasterApiKey + prepareMasterApiKey exported outside ApiKeyFCIS namespace
domains/core/src/api-key/index.ts
Top-level named exports bypass ApiKeyFCIS namespace convention. Add to namespace or move to cross-domain.
Migration files missing trailing newline
packages/database/drizzle/0067_premium_terrax.sql
0067 sql and _journal.json end without newline. Permanent diff noise.
PublicApiKeyResourceSchema satisfies z.ZodType correctly — no drift risk
packages/api/src/schemas/public/api-key.public-schemas.ts
is_master added to both interface and schema with satisfies. No action needed.
tests12
IDOR harness uses non-master caller — write route probes now 403 for wrong reason
apps/platform/src/__tests__/integration/public-v1-idor.test.ts:141
Harness mints callerKey without master credential. Write routes return 403 ApiKeyNotMaster before cross-org check. Cross-org coverage silently lost for create/rotate/revoke routes.
Kill-switch test never asserts the master key own final status
apps/platform/src/__tests__/integration/public-v1-api-keys.test.ts:258
Assertion loop iterates only child keys a and b. A regression leaving master in rotating/active state would pass this test.
Disposable orgs leak — afterAll deletes api_keys rows but not org or membership rows
apps/platform/src/__tests__/integration/public-v1-api-keys.test.ts:165
freshOrgWithMaster creates org+membership+master key rows. afterAll only cleans api_keys by org id. Org and membership rows accumulate across runs.
No handler-level test for DELETE 409 API_KEY_MASTER_NOT_DELETABLE
apps/platform/src/__tests__/integration/public-v1-api-keys.test.ts
Contract declares 409 for master delete. No integration test fires DELETE /v1/api-keys/:id against the master and asserts 409 with API_KEY_MASTER_NOT_DELETABLE.
No test for one-active-master-per-org unique index enforcement
domains/core/src/api-key/__tests__/api-key.integration.test.ts:333
Partial unique index is the last-resort guard against double-master. insertMasterApiKey called twice for same org should hit the constraint. Untested.
No shell-level test: master rotation must preserve isMaster=true, createdByApiKeyId=null
domains/core/src/api-key/__tests__/api-key.integration.test.ts
Decision test covers pure function. No shell test confirms the resulting DB row has correct is_master and created_by_api_key_id values post-rotation.
Cross-org isolation not directly tested for master key operations
apps/platform/src/__tests__/integration/public-v1-api-keys.test.ts
orgA and orgB masters exist in fixture but no test uses orgB master to attempt operations against orgA keys.
Non-master rotate 403 test does not assert error body
apps/platform/src/__tests__/integration/public-v1-api-keys.test.ts:344
Only asserts status 403. A swap from forbidden to insufficient_scope in the mapper would pass silently.
Integration test uses tx as never to call insertMasterApiKey
domains/core/src/api-key/__tests__/api-key.integration.test.ts:344
as never cast suppresses a type incompatibility. Use the shell or fix DbOrTx typing.
Cap test does not verify master is excluded from the active-key count
domains/core/src/api-key/__tests__/api-key.integration.test.ts:86
countActiveByOrg excludes masters but the exclusion is never exercised in the cap suite.
granterIsMaster=undefined (omit) path not explicitly unit-tested in decideRotate
domains/core/src/api-key/__tests__/api-key.decisions.test.ts:309
Implicit in baseline tests. Add an explicit backward-compat assertion for omitted field.
Outbox events for insertMasterApiKey not asserted in integration test
domains/core/src/api-key/__tests__/api-key.integration.test.ts
insertMasterApiKey writes outbox but shell tests do not assert core.api_key.created event was written atomically.
improvement5
Duplicate 403 forbidden literal in public api-keys mapper
apps/platform/src/api/mappers/public-v1/api-keys.mapper.ts:86
Same inline 403 forbidden object in mapPublicCreateApiKeyError and mapPublicRotateApiKeyError. File already has internalError() and notFound() helpers; add forbidden() to match.
tokenForOrg / tokenFor near-duplicate test helpers
apps/platform/src/__tests__/integration/public-v1-api-keys.test.ts:42
Differ only in org source and scopes parameterization. Collapse to one helper.
childWithScopes re-fetches master row from DB on every call
apps/platform/src/__tests__/integration/public-v1-api-keys.test.ts:117
Cache master row in beforeAll instead of re-querying per test invocation.
updated.orgId guard in revoke shell redundant given DB CHECK constraint
domains/core/src/api-key/api-key.shells.ts:345
DB CHECK api_keys_master_is_org_scoped guarantees orgId non-null when isMaster. Harmless safety-net.
Two org-creation shells replicate identical master-seeding block
domains/core/src/organization/organization.shells.ts:69
Acceptable now; consolidate if both shells survive W3 convergence.
History · 47 commits
- 82bb5b9blockedincremental5H · 5M · 4L2026-08-12 01:48
- 90aa3d5needs attentionincremental1H · 5M · 3L2026-08-11 19:37
- 29d19a0needs attentionincremental1H · 5M · 9L2026-08-11 17:41
- 9bd8a0cneeds attentionfull0H · 5M · 9L2026-08-11 02:14
- 62ec3f7needs attentionincremental2H · 5M · 6L2026-08-10 22:51
- f93bca9needs attentionincremental2H · 5M · 8L2026-08-10 17:51
- 052db6fneeds attentionincremental1H · 3M · 4L2026-08-09 21:13
- 45699caneeds attentionincremental0H · 7M · 11L2026-08-09 17:44
- b843d8aneeds attentionincremental1H · 7M · 9L2026-08-09 04:05
- e1757b8needs attentionincremental0H · 3M · 6L2026-08-05 02:11
- 7a762faneeds attentionincremental2H · 5M · 5L2026-08-05 01:25
- 3300a60needs attentionincremental2H · 4M · 7L2026-08-04 19:06
- 0c8a7f5needs attentionincremental0H · 4M · 9L2026-08-04 18:15
- 345f42eneeds attentionincremental2H · 6M · 9L2026-08-04 17:28
- 8338a9aneeds attentionincremental5H · 14M · 14L2026-08-04 00:33
- 41be4c3needs attentionincremental0H · 5M · 7L2026-08-03 23:49
- 5ed593dneeds attentionincremental1H · 6M · 6L2026-08-03 21:32
- b333e25needs attentionincremental4H · 9M · 8L2026-08-03 21:00
- 5642cccneeds attentionincremental2H · 3M · 2L2026-08-03 20:17
- 73b0b39needs attentionincremental3H · 10M · 13L2026-07-31 18:29
- b19852eneeds attentionincremental0H · 1M · 5L2026-07-29 05:04
- 3845205needs attentionincremental3H · 6M · 4L2026-07-29 04:47
- eb8eb50needs attentionincremental0H · 1M · 2L2026-07-29 03:03
- f4720a3needs attentionincremental6H · 8M · 7L2026-07-29 02:54
- f8d341ablockedincremental2H · 2M · 5L2026-07-29 00:00
- a7f1a64needs attentionincremental2H · 8M · 8L2026-07-28 18:41
- 738b60bblockedincremental3H · 6M · 5L2026-07-28 00:46
- 2c248b6needs attentionincremental8H · 12M · 8L2026-07-27 23:23current
- 1346cc0needs attentionincremental2H · 8M · 6L2026-07-27 20:15
- 0716018needs attentionincremental2H · 11M · 12L2026-07-27 19:22
- 215cd2dneeds attentionincremental3H · 6M · 5L2026-07-27 17:04
- ec46958needs attentionincremental0H · 3M · 5L2026-07-27 16:51
- de7b337blockedincremental4H · 9M · 14L2026-07-27 06:36
- b1bb9c0needs attentionincremental1H · 2M · 4L2026-07-27 05:09
- 4701d11needs attentionincremental0H · 4M · 3L2026-07-27 04:44
- e1626c4needs attentionincremental3H · 9M · 10L2026-07-27 03:21
- 195f198needs attentionincremental3H · 3M · 3L2026-07-25 01:22
- 42c7358safeincremental0H · 0M · 0L2026-07-22 20:46
- 85b9018needs attentionincremental0H · 1M · 6L2026-07-21 23:51
- a7b2a9aneeds attentionincremental0H · 9M · 12L2026-07-21 18:49
- c2ee0daneeds attentionincremental4H · 7M · 7L2026-07-21 02:17
- e8ffa5eneeds attentionincremental4H · 7M · 5L2026-07-21 01:33
- a2d2a54needs attentionincremental2H · 7M · 3L2026-07-21 00:51
- 576fbd6needs attentionfull1H · 6M · 7L2026-07-21 00:35
- d3465e8needs attentionincremental1H · 7M · 10L2026-07-21 00:23
- dc794a7needs attentionincremental0H · 5M · 5L2026-07-20 23:46
- 9082773needs attentionfull1H · 3M · 3L2026-07-20 23:13