feat(auth): expose :specify-id via private-intel scope#1525
Conversation
Adds a JWT sub-scope under private-intel that confers the :specify-id capability, mirroring the existing private-intel/import-bundle pattern. The broad private-intel and private-intel:write scopes are intentionally unchanged and still do NOT confer :specify-id, so existing clients are unaffected. Granting the new sub-scope to any client remains an explicit operator decision. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Granting :specify-id via a dedicated sub-scope would force operators to provision a new scope for every existing private-intel writer. Bundle the capability with :write access on the entity root scope instead — same mechanism as :import-bundle — so any client already trusted with broad private-intel writes inherits it without a config change. Trade-off accepted: clients with private-intel:write that should not be allowed to specify entity IDs no longer have a separate gate. Restricting that would require a more granular access model than the current scope shape supports. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…id sub-scope In addition to the broad private-intel:write grant, expose :specify-id through the path-length-2 sub-scope form (private-intel/specify-id). Mirrors the dual exposure pattern used for :import-bundle: any operator who wants to grant only the ID-specification capability without the rest of private-intel:write can do so via the dedicated sub-scope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… :id - New deftest bundle-import-with-jwt-specify-id-test in ctia.entity.web-test signs a real JWT with the test cert, POSTs /ctia/bundle/import with a caller-supplied :id on a sighting, and asserts the entity is created with that exact id and is retrievable via GET. This is the first test that covers the full path: JWT scope -> capability mapping -> flow-layer gate -> bundle import. - Update test-judgement-with-jwt-routes: the assertion that a private-intel JWT cannot specify an :id on POST /ctia/judgement no longer reflects the new mapping. Flip it to assert that the supplied :id is honored (201 + echoed id), preserving the JWT-level intent of the original test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A second bundle import that reuses a caller-supplied :id silently overwrites the first record: ctia.stores.es.crud/handle-create calls ductile.doc/bulk-index-docs (ES index = upsert), the flow layer has no pre-existence check, and the response reports "created" both times. Document the gap as a pinned test so any future collision guard surfaces here. The POC PR should NOT ship before this gap is addressed in the flow layer (and possibly the store layer). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A JWT carrying private-intel (and thus :specify-id via this PR) from one org can replace a document owned by another org by reusing its :id. The test signs a fresh JWT with a different org/id claim, POSTs a bundle reusing Org A's incident :id, and asserts the safe property: - Org A's record is NOT modified - the cross-org POST is NOT reported as "created" Today all three assertions fail, confirming a cross-tenant write path. The flow-layer create path (ctia.flows.crud/find-create-entity-id) does not consult the existing document's owner/groups; the ES handle-create ignores its _ident parameter and uses ductile.doc/bulk-index-docs (an upsert). Together they let any :specify-id holder from any org silently replace any other org's document. This test stays RED until a pre-existence + allow-write? guard is added to the create path, mirroring what handle-update already does. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
handle-create at the ES layer is an unconditional upsert (ductile.doc/bulk-index-docs emits an :index bulk action) and ignores its ident argument. So any holder of :specify-id could POST a bundle or single entity with :id <existing-id> and overwrite the owning org record — exactly the surface PR #1525 opens up by exposing :specify-id via private-intel:write and private-intel/specify-id:write. Fix at the flow layer (ctia.flows.crud/find-create-entity-id), which is the single policy boundary that both POST /ctia/{entity} and POST /ctia/bundle/import traverse. When the caller supplies an :id and a document with that id already exists, return an :id-collision-error instead of letting the upsert proceed. Mirrors what handle-update already does at the store layer (get-docs-with-indices + allow-write?). The flow layer reads via the existing get-fn plumbing used by update-flow / patch-flow, so the change is small and non-invasive: - create-flow accepts an optional :get-fn and exposes :get-prev-entity on the flow map. find-create-entity-id consults it for caller-supplied ids (transient and minted ids are skipped - they can't collide). - bulk/create-entities passes read-entities as :get-fn (bundle import and bulk create-bulk path). - The single-entity POST route in http/routes/crud.clj passes its get-by-ids-fn as :get-fn. - entity/feed and entity/relationship callers are unchanged: :get-fn is optional and the legacy no-collision-check behavior is preserved for paths that don't expose caller-supplied ids. Same-org reuse of an existing :id is rejected with the same collision error - updates have their own endpoint (PUT /:id), the create path should not double as an update path. Out of scope for this PR (worth flagging in review): - valid-short-id? does not bind ids to a tenant/hostname, so even with this guard a caller-supplied id is still cross-tenant in shape. - Whether :specify-id should be dropped from the broad private-intel:write grant and kept only on the narrow sub-scope is a scope-policy call, not a code change. - A TLP=red doc owned by org A is invisible to org B's read-records (suppress-access-control-error? returns nil), so the collision check would miss it and the upsert would still succeed. The test cases here use the default TLP=green where read is allowed; closing the TLP=red gap requires a store-layer ACL-bypass read which is a wider change. Tests: - flows.crud-test/find-create-entity-id-collision-test pins the unit behavior with a mocked get-prev-entity. Runs without ES. - entity.web-test/bundle-import-with-jwt-specify-id-test (the existing RED test) flips green; additional sub-tests cover same-org reuse and the brand-new-id regression case. - entity.web-test/single-entity-post-with-caller-supplied-id-test pins the same safety property directly on POST /ctia/sighting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a top-level OAuth2 scope `ctia-specify-id` that confers a single capability (`:specify-id`) and only when the access suffix is exactly `:write`. The bare scope and the `:read` form confer nothing. This scope is a sibling root scope, decoupled from `private-intel` and from any entity-level scope. Holding broad write access on a resource no longer implies the ability to specify ids on create; callers must hold the dedicated scope explicitly. Provisioning is therefore narrow and per-client, and the capability can be granted without widening any other authorization. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous commit added a :get-prev-entity entry to create-flow's FlowMap unconditionally, with a nil value when callers do not pass :get-fn. FlowMap's :get-prev-entity is an optional-key schemaed as (s/pred fn?), so the nil value fails validation and every create through callers that do not provide :get-fn (single-entity feed POST, relationship POST, bundle export) returns 500. Wrap the assoc in cond-> so the key is only present when a real function exists.
yogsototh
left a comment
There was a problem hiding this comment.
Note
Automated review -- a fresh pass runs within ~1h of each push.
Clean PRs at risk:low/risk:medium (or labeled BOT_APPROVABLE) are approved automatically; risk:high+ needs a human.
Verdict: APPROVED -- 0 CRITICAL, 0 HIGH, 1 MEDIUM
Findings
- MEDIUM [cross-cutting] -- Intra-batch duplicate IDs bypass collision check:
prev-entitypre-fetches existing docs at flow start, so two entities in the same bundle sharing a fresh caller-supplied:idboth pass the guard and ES last-write-wins applies. Not a regression (the upsert existed before), and the PR doesn't claim to handle it, but worth noting as a residual gap if:specify-idcallers ever batch-import with duplicate IDs. (src/ctia/flows/crud.clj:create-flow)
PASS: Security, Correctness, Code Quality, Tests, Style
The scope mapping is strict (ctia-specify-id:write only, path-length-1 only), the collision guard is correctly wired through both single-entity POST and bundle import paths, the cond-> fix for callers without :get-fn is correct, and test coverage is comprehensive (unit + integration, cross-org, same-org reuse, happy-path regression).
Non-blocking follow-ups
- The TLP=red visibility gap (acknowledged in the PR) means a cross-org attacker who knows a TLP=red doc's ID can still overwrite it via
:specify-id. Closing this requires a store-layer ACL-bypass read -- a wider change, correctly out-of-scope here. - Consider whether intra-batch duplicate-ID detection (a simple
(frequencies (keep :id entities))check at flow entry) is worth adding as a fast-fail before the store round-trip.
-- auto-review-team (BOT_APPROVABLE gate)
`prev-entity` pre-fetches existing docs once at flow start, so two entities in the same batch sharing a fresh caller-supplied :id both pass the collision guard in find-create-entity-id and let ES last-write-wins apply. Add a detect-duplicate-ids stage that runs after transient ids are resolved and rejects any entity whose normalized short-id appears more than once in the batch. Normalization collapses long/short encodings of the same id so they also count as a duplicate. Transient ids and entities without :id are ignored. Addresses the MEDIUM review finding on #1525.
yogsototh
left a comment
There was a problem hiding this comment.
Note
Automated review -- a fresh pass runs within ~1h of each push.
Clean PRs at risk:low/risk:medium (or labeled BOT_APPROVABLE) are approved automatically; risk:high+ needs a human.
Re-review (10 commits, +491/-66)
Clean. No blocking issues found.
What this PR does
- Adds a dedicated
ctia-specify-id:writeOAuth2 scope that confers only:specify-id(decoupled fromprivate-intel:write) - Adds a flow-layer collision guard in
find-create-entity-id: caller-supplied:idthat resolves to an existing document is rejected with:id-collision-error(prevents cross-tenant overwrite via the ES upsert) - Adds intra-batch duplicate detection (
detect-duplicate-ids) to catch two entities in the same create batch sharing a fresh ID (which would bypass the pre-fetchedget-prev-entity) - Comprehensive test coverage: unit (
find-create-entity-id-collision-test,detect-duplicate-ids-test), integration (bundle import + single-entity POST), cross-org and same-org reuse assertions
Design notes (not blocking)
- The acknowledged TLP-red visibility gap (a doc invisible to the caller's read bypasses the collision check) is a pre-existing ACL limitation, not a regression. Closing it requires a store-layer ACL-bypass read -- reasonable to leave out of scope.
find-create-entity-iddeclares:- s/Strbut can return an error map -- this is a pre-existing pattern in that function (the "Invalid entity ID" branch already returns a map). Not introduced here.- The
prev-entityclosure pre-fetches once at flow start, so the guard is O(1) per entity after the initial batch read. Good trade-off for the common case (most creates don't supply:id).
-- auto-review-team (BOT_APPROVABLE gate)
yogsototh
left a comment
There was a problem hiding this comment.
Note
Automated review -- a fresh pass runs within ~1h of each push.
Clean PRs at risk:low/risk:medium (or labeled BOT_APPROVABLE) are approved automatically; risk:high+ needs a human.
Clean on re-review. The scope design, collision guard, and test coverage all look solid.
- Scope isolation --
ctia-specify-id:writeconfers only:specify-id; bare scope and:readconfer nothing; not reachable viaprivate-intel:write. Correct and minimal. - Collision guard --
find-create-entity-idnow rejects caller-supplied:idwhen a document already exists at that ID, closing the ES-upsert cross-tenant overwrite gap on the create path. - Intra-batch dedup --
detect-duplicate-idscatches two entities sharing the same fresh:idwithin a single bundle import (where pre-fetch would see both as "new"). - Backward compat --
:get-prev-entityis optional (cond->only assocs it whenget-fnis provided), so callers not passing:get-fnretain legacy behavior. - TOCTOU residual -- acknowledged in the PR body; the flow-layer check is defense-in-depth, not a distributed lock. Acceptable tradeoff.
- Test coverage -- unit tests for both pure functions plus end-to-end integration tests covering bundle-import and single-entity POST across same-org re-create, cross-org attack, and fresh-id regression.
No blocking findings.
-- auto-review-team (BOT_APPROVABLE gate)
Summary
Exposes the
:specify-idcapability through a dedicated top-level OAuth2 scope and closes the cross-tenant overwrite gap that surfaces when caller-supplied IDs become reachable outside the static admin role.What changes
Dedicated
ctia-specify-idscopeA new sibling root scope is added in
src/ctia/auth/jwt.clj:ctia-specify-id:write— confers#{:specify-id}and nothing elsectia-specify-id(no access) — confers nothingctia-specify-id:read— confers nothingThe scope is intentionally NOT a subscope of
private-intel(or any entity-root scope), so grantingprivate-intel:writedoes not implicitly carry:specify-id. Least-privilege provisioning: a client that should be able to specify entity IDs holds exactly this one scope, and gains no entity read/write/delete authority from it.Scope name is configurable via
ctia.auth.specify-id.scope(default"ctia-specify-id"), following the same pattern asentity-root-scope/casebook-root-scope/assets-root-scope.Flow-layer collision check on caller-supplied
:id:specify-idonly gates the capability to name an entity at create time. It does not by itself protect against a caller supplying an ID that already exists and belongs to another org —handle-createwrites via an ES upsert and does not check the existing document's ACL.src/ctia/flows/crud.cljfind-create-entity-idnow performs a pre-existence lookup whenever a caller supplies:id. If the ID resolves to an existing document, the flow rejects the create with:id-collision-error. The sameprev-entity+:get-fnplumbingupdate-flowalready uses is reused; bothPOST /ctia/{entity}andPOST /ctia/bundle/importtraverse the new check.Why
Today
:specify-idis reachable only via the static admin role — no OAuth2 scope maps to it. A dedicated narrow scope lets operators grant just this capability without conferring full admin identity or any entity-level write authority.Trade-offs
:idon the create path. Bundle imports without:id(the common case) are unaffected.Out of scope
valid-short-id?validates format only; it does not bind IDs to a tenant or hostname.QA
No QA is needed. Covered by
scopes-to-capabilities-testandfind-create-entity-id-collision-test, plus end-to-end JWT integration coverage inctia.entity.web-test/bundle-import-with-jwt-specify-id-testandsingle-entity-post-with-caller-supplied-id-test.Release Notes