Skip to content

feat(auth): expose :specify-id via private-intel scope#1525

Open
ereteog wants to merge 10 commits into
masterfrom
feat/specify-id-scope
Open

feat(auth): expose :specify-id via private-intel scope#1525
ereteog wants to merge 10 commits into
masterfrom
feat/specify-id-scope

Conversation

@ereteog

@ereteog ereteog commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Related https://cisco-sbg.atlassian.net/browse/XDR-52722

Summary

Exposes the :specify-id capability 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-id scope

A new sibling root scope is added in src/ctia/auth/jwt.clj:

  • ctia-specify-id:write — confers #{:specify-id} and nothing else
  • ctia-specify-id (no access) — confers nothing
  • ctia-specify-id:read — confers nothing

The scope is intentionally NOT a subscope of private-intel (or any entity-root scope), so granting private-intel:write does 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 as entity-root-scope / casebook-root-scope / assets-root-scope.

Flow-layer collision check on caller-supplied :id

:specify-id only 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-create writes via an ES upsert and does not check the existing document's ACL.

src/ctia/flows/crud.clj find-create-entity-id now 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 same prev-entity + :get-fn plumbing update-flow already uses is reused; both POST /ctia/{entity} and POST /ctia/bundle/import traverse the new check.

Why

Today :specify-id is 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

  • The flow-layer check costs one additional store read per caller-supplied :id on the create path. Bundle imports without :id (the common case) are unaffected.
  • The check uses the same access-controlled read as the existing flow; if a TLP=red document owned by another org is unreadable to the caller, the collision is currently not detected. This is a residual gap consistent with the existing ACL posture and is called out as a known limitation, not a regression introduced here.

Out of scope

  • valid-short-id? validates format only; it does not bind IDs to a tenant or hostname.
  • Operator documentation and changelog beyond what is in this PR.

QA

No QA is needed. Covered by scopes-to-capabilities-test and find-create-entity-id-collision-test, plus end-to-end JWT integration coverage in ctia.entity.web-test/bundle-import-with-jwt-specify-id-test and single-entity-post-with-caller-supplied-id-test.

Release Notes

intern: :specify-id is now conferred by the dedicated ctia-specify-id:write OAuth2 scope (previously admin-only). Caller-supplied :id on create is now rejected when it collides with an existing document the caller is not authorized to write.

ereteog and others added 2 commits June 23, 2026 19:25
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>
@ereteog ereteog changed the title feat(auth): map private-intel/specify-id scope to :specify-id capability feat(auth): grant :specify-id via private-intel:write scope Jun 23, 2026
…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>
@ereteog ereteog changed the title feat(auth): grant :specify-id via private-intel:write scope feat(auth): expose :specify-id via private-intel scope Jun 23, 2026
ereteog and others added 3 commits June 23, 2026 20:59
… :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>
@ereteog ereteog closed this Jun 29, 2026
@ereteog ereteog reopened this Jun 29, 2026
claude and others added 3 commits June 29, 2026 12:09
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 yogsototh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-entity pre-fetches existing docs at flow start, so two entities in the same bundle sharing a fresh caller-supplied :id both 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-id callers 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 yogsototh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:write OAuth2 scope that confers only :specify-id (decoupled from private-intel:write)
  • Adds a flow-layer collision guard in find-create-entity-id: caller-supplied :id that 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-fetched get-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-id declares :- s/Str but 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-entity closure 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)

@ereteog ereteog added the BOT_APPROVABLE Indicate the author of this PR consider we could merge from AI-only review. label Jun 29, 2026

@yogsototh yogsototh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:write confers only :specify-id; bare scope and :read confer nothing; not reachable via private-intel:write. Correct and minimal.
  • Collision guard -- find-create-entity-id now rejects caller-supplied :id when a document already exists at that ID, closing the ES-upsert cross-tenant overwrite gap on the create path.
  • Intra-batch dedup -- detect-duplicate-ids catches two entities sharing the same fresh :id within a single bundle import (where pre-fetch would see both as "new").
  • Backward compat -- :get-prev-entity is optional (cond-> only assocs it when get-fn is provided), so callers not passing :get-fn retain 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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BOT_APPROVABLE Indicate the author of this PR consider we could merge from AI-only review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants