← all branches

feat/one-api

needs attentionviewing older commit
b333e25 · incrementalpre-PRreviewed 2026-08-03 21:00 UTC4H · 9M · 8L · 7I
The branch
Purpose
Close the tenant isolation gap in the public v1 API: machine callers (bk_ API keys) authenticate with a JWT that carries no Supabase uid, so row-level security policies — which fire inside transactions — were never applied to public-v1 reads. Every read ran on the service-role connection, with only application-layer orgId filters as the isolation boundary.
Goal
Bring public-v1 reads under database-enforced tenant isolation (RLS) by wrapping them in org-bound transactions, matching the two-controls pattern already used by write paths.
Sub-goals
  • SG-1: Establish machine-caller identity on the Postgres session (migration 0069 + createMachineRLSDb)
  • SG-2: Persist request telemetry so the baseline is observable
  • SG-3: Record WHICH credential internal callers present
  • SG-4: Wrap the 16 public-v1 read sites in readInOrg — this commit
The changes (whole branch)
What
One new utility `readInOrg(auth, orgId, read)` wraps a callback in `rlsDbFor(auth, orgId).transaction(read)`. Sixteen read call sites across bills, files, jobs, monitoring, payment-status, and savings handlers now pass through it. Three categories deliberately excluded: admin-gated tables (webhooks, api_keys, invitations), global catalogs, and the ZIP route (fused read+Lambda call). A positive-path suite added to the IDOR integration test: fires each converted route as the owning org and asserts data returns.
Why
RLS applies only inside a transaction. Reads that ran directly on the service-role connection bypassed all 88 policies — the isolation was single-control (application filter) rather than the two-control design the system was architected for.
Areas
apps/platform/src/api/handlers/public-v1+116100apps/platform/src/api/utils+18212apps/platform/src/__tests__/integration+4227.claude/rules+2335docs+5090domains/core+800100apps/platform/src/lib+1000
Blast
~50 files changed across the full branch; this commit touches 10 files in apps/platform/src/api — all public-v1 read handlers + the RLS utility + IDOR test + one-api.md rule.
security-sensitive: RLS wrapping no-PR: pre-PR branch
CI· No PR open — CI signals unavailableCodeRabbit· No .coderabbit.yaml in repo

Findings · 29

correctness7

medium

SavingsConfig findByPublicId has no org-id predicate — single control when configPublicId is provided

apps/platform/src/api/handlers/public-v1/savings.handler.ts:125

findByPublicId(tx, configPublicId) queries only by publicId. Protection is: (a) RLS via readInOrg, and (b) a post-query guard checking config.siteId === site.siteId && config.orgId === orgId. The public-v1-rls.ts docstring promises 'two independent controls — the query keeps its orgId argument'. This route breaks that invariant: if the RLS policy were reverted, only the post-query guard would remain. findEnabledBySite is scoped by siteId and is fine; only the findByPublicId branch is missing the orgId predicate.

medium

loadMonitoringHealthByRpu runs on service-role connection — single control for this path

apps/platform/src/api/handlers/public-v1/monitoring.handler.ts:119

After the RLS-wrapped getAccessibleContractPublicIds + listMonitoringStatusByContractPublicIds pair, loadMonitoringHealthByRpu(database, orgId, ...) runs on the bare service-role connection. The contractPublicIds argument is already narrowed by the RLS transaction, so no cross-org data is reachable — but it is application-level scoping only, not a two-control path. Noted departure from the two-controls principle stated in the docstring.

low

jobsDetailHandler: app-level org check is structurally unreachable — RLS returns null first

apps/platform/src/api/handlers/public-v1/jobs.handler.ts:267

findByPublicId has no org_id SQL predicate; it queries by publicId only. The org boundary is enforced by RLS (cfe_jobs_select_member). Under readInOrg, a cross-org job returns undefined → JobNotFound before the job.orgId !== orgId check fires. The check is correct defence-in-depth but structurally unreachable. Not a bug — both paths produce an identical 404 body — but worth a comment for future auditors: `// Structural defence-in-depth: RLS returns null for cross-org jobs before this line is reached`.

info

readInOrg async handling confirmed correct — Drizzle transaction wrapper properly awaits callback

apps/platform/src/api/utils/public-v1-rls.ts:97

rlsDbFor(auth, orgId).transaction(read) — the Proxy's transaction intercept wraps fn(tx) in an inner async callback and returns its result. Drizzle awaits the callback. No missing await, no fire-and-forget. Correct.

info

monitoringRemoveHandler write path correctly stays on service-role — no authenticated write policy

apps/platform/src/api/handlers/public-v1/monitoring.handler.ts

The deactivation shells (deactivateMonitoringShell, deactivatePaymentMonitoringShell) use database (service-role). monitoring_subscriptions has no authenticated write policy; running under RLS would deny the write (same rationale as BAT-291). Pattern correct.

info

zipFilesHandler: no cross-org data leak in the fused read+Lambda path

apps/platform/src/api/handlers/public-v1/files.handler.ts

generatePublicFilesZip calls getAccessibleContractPublicIds before any data fetch. No cross-org data is reachable before the Lambda invocation. The exclusion from readInOrg is correctly documented.

info

webhooksList positive-path test correctly validates service-role behavior — appropriate exception

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:631

The test fires webhooksList as org B and asserts orgB.webhookPublicId is present, proving service-role access returns data for the owning org. Comment correctly explains that this route must NOT be RLS-wrapped (admin-gated table). Test caught the original regression when it was first wrapped and returned empty. Correct design.

security5

medium

Webhook list/delete accessible to non-admin machine credentials (known, unresolved disagreement)

apps/platform/src/api/handlers/public-v1/webhooks.handler.ts:156

webhooksListHandler reads through service-role with orgId filter, bypassing the DB policy webhook_endpoints_select_admin (gates on get_user_admin_org_ids()). Any machine credential for org X can list/delete org X's webhooks regardless of privilege level. The handler comment explicitly acknowledges this as an unresolved disagreement. An attacker with a low-privilege leaked bk_ key can enumerate webhook URLs and event configurations for its org. No new regression — this gap predates this commit — but documenting the planned fix (widen get_user_admin_org_ids for machine callers at webhook endpoints, or add an admin-role gate at the handler layer) would move it forward.

medium

ZIP route is single-control — no DB-level isolation fallback (documented gap)

apps/platform/src/api/handlers/public-v1/files.handler.ts:149

zipFilesHandler is deliberately excluded from readInOrg (transaction would hold a Postgres connection open across a Lambda round-trip). Application-layer orgId + getAccessibleContractPublicIds is the sole gate. The comment documents the fix (split read from Lambda invocation so the read can be wrapped). No exploitable cross-org path exists today. The gap is real and documented — the right next step is splitting the orchestrator.

low

readInOrg transactions have no statement_timeout — potential pool exhaustion under adversarial load

packages/database/src/rls.ts:40

createRLSDb and createMachineRLSDb do not apply SET LOCAL statement_timeout inside the transaction. client.ts recommends per-transaction SET LOCAL as the correct place. Under concurrent API load, slow queries could hold connections open for the full idle timeout (20s). Bounded by Vercel's serverless function timeout in practice, but adding SET LOCAL statement_timeout = '10s' after the SET LOCAL ROLE call would close the gap defensively.

low

TOCTOU across three savings transactions — theoretical, no exploitable path

apps/platform/src/api/handlers/public-v1/savings.handler.ts:100

Site resolved in tx1, config in tx2, report in tx3. Org transfer of a site between tx1 and tx2 is the theoretical attack surface; the application guard config.orgId !== orgId catches it. Site org transfer is not a supported operation. Merging into one readInOrg transaction eliminates the concern entirely and is the cleaner fix.

info

rlsDbFor used outside .transaction() silently falls through to service-role — potential footgun

apps/platform/src/api/utils/public-v1-rls.ts:108

The docstring warns that bare rlsDbFor(auth, orgId).select(...) passes straight through to the admin connection. All current call sites use readInOrg, which enforces the transaction. Consider making rlsDbFor unexported and requiring callers to go through readInOrg.

conventions3

high

readInOrg JSDoc is a 44-line multi-paragraph block — explicit convention violation

apps/platform/src/api/utils/public-v1-rls.ts:47

CLAUDE.md: 'Never write multi-paragraph docstrings or multi-line comment blocks — one short line max.' The function body runs 44 lines (lines 47–91) covering why RLS exists, four NOT-FOR categories, and a general rule. The same content already lives in .claude/rules/one-api.md. Reduce to: `// Org-bound RLS read — see one-api.md § 'Reads now run under it too' for exclusion rules.`

medium

readInOrg tx parameter typed as opaque Parameters<...> chain instead of Transaction

apps/platform/src/api/utils/public-v1-rls.ts:95

`read: (tx: Parameters<Parameters<Database['transaction']>[0]>[0]) => Promise<T>` resolves to `Transaction` from @batu/shared-kernel — the same type used in every *.queries.ts file as `DbOrTx`. Replace with `import type { Transaction } from '@batu/shared-kernel'` and `read: (tx: Transaction) => Promise<T>`. The existing createRLSDb and createMachineRLSDb callbacks already use Transaction directly.

medium

savingsReportGetHandler opens three separate readInOrg transactions for one logical read

apps/platform/src/api/handlers/public-v1/savings.handler.ts:115

Lines 115, 123, and 133 each open a separate org-bound transaction (site → config → report). Three Postgres round-trips for data that is logically a single snapshot read. The monitoring handler explicitly batches its two-query pair ('One transaction for the pair — a single logical read'). These three reads have a sequential dependency chain (site → config.siteId → report.configId), so they CAN be batched: one readInOrg that resolves site, then config if site found, then report if config found. Also applies to savingsConfigsListHandler (two transactions, lines 211 + 215).

tests9

high

filesList skip comment claims rls-machine-claim.test.ts covers bill_files — it doesn't

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:648

Comment: 'Its READ is covered by the bill_files policy exercise in rls-machine-claim.test.ts.' That file tests only cfe_jobs and api_keys — bill_files are absent. The claim is false, so the justification for skipping the positive-path assertion is ungrounded. filesListHandler calls listPublicFiles inside readInOrg; if the bill_files SELECT policy were too narrow for machine callers, the handler silently returns 200 with an empty list and no test catches it.

high

savingsReportGetHandler positive path missing — three readInOrg calls, none verified to return data

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:611

savingsReportGetHandler (savings.handler.ts) issues three separate readInOrg calls: findSiteInOrg, config lookup, report lookup. A misconfigured savings_configs or savings_reports SELECT policy returns null silently → handler returns 404. No positive-path test fires this as org B (the owner) to confirm data comes back. This is exactly the failure mode the positive-path suite was introduced to catch.

high

savingsConfigsListHandler positive path missing — broken RLS returns 200 with empty array

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:611

savingsConfigsListHandler calls readInOrg twice (findSiteInOrg → listBySite). A policy that is too narrow for machine callers on savings_configs returns listBySite as an empty array with 200 — indistinguishable from no data. No positive-path assertion covers this route.

medium

monitoringList/paymentStatusList positive-path asserts 200 only — not discriminating against silent empty result

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:637

The comment says 'An empty result is legitimate here' but org B has seeded data (orgB.contractPublicId). An over-narrow RLS policy on utility_contracts or monitoring_subscriptions would also return 200 + empty array. The test cannot distinguish a correct result from a broken RLS. Since orgB.rpu is seeded, asserting the response contains it would give the test real discriminating power.

medium

jobsDetailHandler positive path missing — readInOrg wraps findByPublicId, no data-return assertion

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:611

jobsDetailHandler wraps jobQueries.findByPublicId in readInOrg. A broken cfe_jobs SELECT policy for machine callers silently returns null → identical 404 to 'not found'. No positive-path test creates a job for org B and asserts GET /v1/jobs/:id returns it. rls-machine-claim.test.ts covers the policy at DB level, but not the handler wiring.

medium

filesDetailHandler positive path missing — readInOrg wraps getPublicFile, no positive assertion

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:611

filesDetailHandler wraps getPublicFile in readInOrg. The AWS-credentials block applies to S3 presigning, not the RLS-wrapped DB query. A positive-path test can assert the handler returns a non-500 for org B's bfl_ id even without S3 creds (it would get a presigning error, not an RLS-empty 404). Currently no such test exists.

low

jobsCreate idempotency read-back (JobAlreadyExists path) not tested — INFRA_DEPENDENT routes skip it

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:362

The readInOrg at jobs.handler.ts line 151 (idempotency read-back on the JobAlreadyExists dedup branch) is in infrastructure-dependent code (SFN/SQS). INFRA_DEPENDENT routes tolerate 500s, so a broken idempotency wiring would not be detected. Low-probability given rls-machine-claim pins the policy, but the branch is an untested code path.

low

monitoringRemoveHandler positive path missing — readInOrg lookup on remove path untested

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:611

monitoringRemoveHandler uses readInOrg to fetch accessible contracts before deactivating. If the RLS lookup silently returns empty for the owning org, the handler returns RPU_NOT_FOUND for the owner. No positive-path probe fires DELETE /v1/monitoring/:rpu as org B against org B's own RPU.

info

No unit tests for readInOrg — actorType branch dispatch covered only via integration

apps/platform/src/api/utils/public-v1-rls.ts:92

The three actorType branches (machine → createMachineRLSDb, user → createRLSDb, service → bare db) are only exercised by integration tests. Logic is a simple switch — integration coverage is sufficient. Worth noting for future refactors.

improvement5

low

savingsConfigsListHandler uses two readInOrg calls where one suffices

apps/platform/src/api/handlers/public-v1/savings.handler.ts:211

findSiteInOrg (line 211) and listBySite (line 215) are two separate transactions with a sequential dependency. One readInOrg returning { site, configs } would halve the round-trips and match the monitoring handler's documented pattern. Currently inconsistent with the 'one transaction for a logical pair' convention.

low

savingsConfigCreateHandler connection split undocumented — unlike monitoringRemoveHandler

apps/platform/src/api/handlers/public-v1/savings.handler.ts:168

The site gate uses readInOrg, then createSavingsConfigShell uses bare database (service-role). This split is correct (no authenticated write policy on savings_configs). Unlike monitoringRemoveHandler which has an explicit comment explaining the split ('The DEACTIVATION below stays on the service-role connection: those shells write subscription + outbox, and monitoring_subscriptions grants no authenticated write policy'), the savings create handler has no explanation. Readers comparing the two handlers may assume the service-role usage is accidental.

low

monitoringRemoveHandler: deactivatePaymentMonitoringShell result silently dropped without explanation

apps/platform/src/api/handlers/public-v1/monitoring.handler.ts:202

deactivatePaymentMonitoringShell returns Result<{deactivated: boolean}, never>. The never error type means it structurally cannot fail, so dropping the result is correct. However, a plain `await` with no assignment looks like a copy-paste oversight compared to deactivateMonitoringShell which checks `!r.ok`. Add `void await` or a one-line comment: `// Result<_, never> — structurally infallible, result dropped`.

info

Positive-path suite covers 5 of 16 converted read sites — savings and files routes have no positive coverage

apps/platform/src/__tests__/integration/public-v1-idor.test.ts:611

billsList/Detail, webhooksList, monitoringList, paymentStatusList are covered. savingsReportGet, savingsConfigsList, filesDetail, monitoringRemove are not. The filesList exclusion is documented; the savings routes have no note.

info

Pattern convention for batched reads is documented in monitoring/payment-status but absent from savings

apps/platform/src/api/handlers/public-v1/savings.handler.ts

monitoring.handler.ts and payment-status.handler.ts carry the comment 'One transaction for the pair — a single logical read, one org-bound snapshot.' The savings handlers that do NOT batch (savingsReportGetHandler — three calls) lack any note explaining why the dependency chain prevents batching. Adding this as a rule in api-patterns.md ('batch related reads into one readInOrg; if sequential calls appear, explain why') would make the asymmetry intentional.

History · 47 commits

  1. 82bb5b9blockedincremental5H · 5M · 4L2026-08-12 01:48
  2. 90aa3d5needs attentionincremental1H · 5M · 3L2026-08-11 19:37
  3. 29d19a0needs attentionincremental1H · 5M · 9L2026-08-11 17:41
  4. 9bd8a0cneeds attentionfull0H · 5M · 9L2026-08-11 02:14
  5. 62ec3f7needs attentionincremental2H · 5M · 6L2026-08-10 22:51
  6. f93bca9needs attentionincremental2H · 5M · 8L2026-08-10 17:51
  7. 052db6fneeds attentionincremental1H · 3M · 4L2026-08-09 21:13
  8. 45699caneeds attentionincremental0H · 7M · 11L2026-08-09 17:44
  9. b843d8aneeds attentionincremental1H · 7M · 9L2026-08-09 04:05
  10. e1757b8needs attentionincremental0H · 3M · 6L2026-08-05 02:11
  11. 7a762faneeds attentionincremental2H · 5M · 5L2026-08-05 01:25
  12. 3300a60needs attentionincremental2H · 4M · 7L2026-08-04 19:06
  13. 0c8a7f5needs attentionincremental0H · 4M · 9L2026-08-04 18:15
  14. 345f42eneeds attentionincremental2H · 6M · 9L2026-08-04 17:28
  15. 8338a9aneeds attentionincremental5H · 14M · 14L2026-08-04 00:33
  16. 41be4c3needs attentionincremental0H · 5M · 7L2026-08-03 23:49
  17. 5ed593dneeds attentionincremental1H · 6M · 6L2026-08-03 21:32
  18. b333e25needs attentionincremental4H · 9M · 8L2026-08-03 21:00current
  19. 5642cccneeds attentionincremental2H · 3M · 2L2026-08-03 20:17
  20. 73b0b39needs attentionincremental3H · 10M · 13L2026-07-31 18:29
  21. b19852eneeds attentionincremental0H · 1M · 5L2026-07-29 05:04
  22. 3845205needs attentionincremental3H · 6M · 4L2026-07-29 04:47
  23. eb8eb50needs attentionincremental0H · 1M · 2L2026-07-29 03:03
  24. f4720a3needs attentionincremental6H · 8M · 7L2026-07-29 02:54
  25. f8d341ablockedincremental2H · 2M · 5L2026-07-29 00:00
  26. a7f1a64needs attentionincremental2H · 8M · 8L2026-07-28 18:41
  27. 738b60bblockedincremental3H · 6M · 5L2026-07-28 00:46
  28. 2c248b6needs attentionincremental8H · 12M · 8L2026-07-27 23:23
  29. 1346cc0needs attentionincremental2H · 8M · 6L2026-07-27 20:15
  30. 0716018needs attentionincremental2H · 11M · 12L2026-07-27 19:22
  31. 215cd2dneeds attentionincremental3H · 6M · 5L2026-07-27 17:04
  32. ec46958needs attentionincremental0H · 3M · 5L2026-07-27 16:51
  33. de7b337blockedincremental4H · 9M · 14L2026-07-27 06:36
  34. b1bb9c0needs attentionincremental1H · 2M · 4L2026-07-27 05:09
  35. 4701d11needs attentionincremental0H · 4M · 3L2026-07-27 04:44
  36. e1626c4needs attentionincremental3H · 9M · 10L2026-07-27 03:21
  37. 195f198needs attentionincremental3H · 3M · 3L2026-07-25 01:22
  38. 42c7358safeincremental0H · 0M · 0L2026-07-22 20:46
  39. 85b9018needs attentionincremental0H · 1M · 6L2026-07-21 23:51
  40. a7b2a9aneeds attentionincremental0H · 9M · 12L2026-07-21 18:49
  41. c2ee0daneeds attentionincremental4H · 7M · 7L2026-07-21 02:17
  42. e8ffa5eneeds attentionincremental4H · 7M · 5L2026-07-21 01:33
  43. a2d2a54needs attentionincremental2H · 7M · 3L2026-07-21 00:51
  44. 576fbd6needs attentionfull1H · 6M · 7L2026-07-21 00:35
  45. d3465e8needs attentionincremental1H · 7M · 10L2026-07-21 00:23
  46. dc794a7needs attentionincremental0H · 5M · 5L2026-07-20 23:46
  47. 9082773needs attentionfull1H · 3M · 3L2026-07-20 23:13