fix(api): validate organizationId in the agent job/contract CRUD tools (#375) #381

Merged
james merged 1 commit from validate-job-org-in-crud-tools into main 2026-06-29 17:35:57 +00:00
Owner

The update_job follow-up from #375 — which turned out to also close a per-user-isolation hole.

The hole

The agent create_job/update_job (and contract) tools use the shared zJobCreate/zJobUpdate schemas, which already accept organizationId (added in #375). The factory write tools passed it straight to the repo unvalidated — so an agent/MCP client could link a job to another user's organization, which then leaked that org's name to the attacker via the read LEFT JOIN. (My #379 PR note that "the generic tools don't expose organizationId" was wrong — they do, through the shared DTO. This corrects it.)

The REST routes were never affected — they validate via resolveJobOrganizationLink. Only the agent/MCP surface was.

The fix

Validate org ownership in the createRow/updateRow callbacks via the same resolveJobOrganizationLink resolver the routes use: a cross-user/missing org id is a 404 (don't leak existence); on a valid link the org's live name is snapshotted into company. Applies to both jobs and contracts, create and update. The tool descriptions now document the organizationId option.

The undo system's generic inverse for a safe_update is update_<entityType> = update_job. Now that update_job carries and validates organizationId, undoing a link (via link_job_to_org or update_job) properly clears it and restores the prior company — instead of the half-apply I flagged in #379.

Verification

  • typecheck ✓ · lint ✓ · openapi:check ✓ (no spec change — agent tools aren't in the spec; the DTOs already had organizationId) · api-client check ✓ · semgrep ✓ 0 findings.
  • test against both engines (ephemeral Postgres): 1272 passed, 0 skipped — incl. new dual-engine tests: create_job/update_job reject a cross-user organizationId (no write, no leak), and a link made via update_job round-trips through undo (clears the link + restores the prior company).

Part of #375.

🤖 Generated with Claude Code

The `update_job` follow-up from #375 — which turned out to also close a **per-user-isolation hole**. ## The hole The agent `create_job`/`update_job` (and contract) tools use the shared `zJobCreate`/`zJobUpdate` schemas, which **already accept `organizationId`** (added in #375). The factory write tools passed it straight to the repo **unvalidated** — so an agent/MCP client could link a job to **another user's organization**, which then leaked that org's name to the attacker via the read `LEFT JOIN`. (My #379 PR note that "the generic tools don't expose organizationId" was wrong — they do, through the shared DTO. This corrects it.) The REST routes were never affected — they validate via `resolveJobOrganizationLink`. Only the agent/MCP surface was. ## The fix Validate org ownership in the `createRow`/`updateRow` callbacks via the **same** `resolveJobOrganizationLink` resolver the routes use: a cross-user/missing org id is a **404** (don't leak existence); on a valid link the org's live name is snapshotted into `company`. Applies to both jobs and contracts, create and update. The tool descriptions now document the `organizationId` option. ## Bonus: `link_job_to_org` is now fully undoable The undo system's generic inverse for a `safe_update` is `update_<entityType>` = `update_job`. Now that `update_job` carries **and validates** `organizationId`, undoing a link (via `link_job_to_org` or `update_job`) properly **clears** it and restores the prior company — instead of the half-apply I flagged in #379. ## Verification - `typecheck` ✓ · `lint` ✓ · `openapi:check` ✓ (no spec change — agent tools aren't in the spec; the DTOs already had `organizationId`) · `api-client check` ✓ · semgrep ✓ 0 findings. - `test` against **both engines** (ephemeral Postgres): **1272 passed, 0 skipped** — incl. new dual-engine tests: `create_job`/`update_job` reject a cross-user `organizationId` (no write, no leak), and a link made via `update_job` round-trips through undo (clears the link + restores the prior company). Part of #375. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(api): validate organizationId in the agent job/contract CRUD tools (#375)
All checks were successful
PR / pnpm audit (pull_request) Successful in 3m10s
PR / OpenAPI (pull_request) Successful in 3m32s
PR / Client (web export smoke) (pull_request) Successful in 3m42s
PR / Lint (pull_request) Successful in 4m9s
PR / Build (pull_request) Successful in 4m14s
PR / OSV-Scanner (pull_request) Successful in 1m55s
PR / Test (postgres) (pull_request) Successful in 4m20s
PR / Package age policy (soft) (pull_request) Successful in 51s
Secrets / gitleaks (pull_request) Successful in 43s
PR / Test (sqlite) (pull_request) Successful in 4m36s
PR / Trivy (image) (pull_request) Successful in 2m38s
PR / Coverage (soft) (pull_request) Successful in 2m10s
PR / E2E (Playwright) (pull_request) Successful in 5m20s
Commits / Conventional Commits (pull_request) Successful in 6s
PR / Typecheck (pull_request) Successful in 2m19s
PR / Static analysis (pull_request) Successful in 2m25s
481fa0d56b
The agent create_job/update_job (+ contract) tools use the shared
zJobCreate/zJobUpdate schemas, which already accept organizationId (#375).
The factory write tools passed it straight to the repo UNVALIDATED — so an
agent/MCP client could link a job to another user's organization, which
then leaked that org's name through the read join. (My #379 note that the
generic tools "don't expose organizationId" was wrong — they do, via the
shared DTO.)

Validate org ownership in the createRow/updateRow callbacks via the same
resolveJobOrganizationLink resolver the REST routes use: a cross-user or
missing org id is a 404 (don't leak existence); on a valid link the org's
live name is snapshotted into company. Applies to both jobs and contracts.

Bonus: this makes link_job_to_org fully undoable — the undo system's
inverse (update_job) now carries + validates organizationId, so undoing a
link clears it (and restores the prior company) instead of half-applying.

Dual-engine tests: create_job/update_job reject a cross-user organizationId
(no write, no leak); a link made via update_job round-trips through undo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 79.6% ≥ 50%
Branches 71.5% ⚠️ ≥ 75%
Functions 81.2% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 79.6% ✅ | ≥ 50% | | Branches | 71.5% ⚠️ | ≥ 75% | | Functions | 81.2% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 91e50a74fd into main 2026-06-29 17:35:57 +00:00
james deleted branch validate-job-org-in-crud-tools 2026-06-29 17:35:58 +00:00
Sign in to join this conversation.
No description provided.