Statement-anchored shared responsibility: by-component reads, Inherited/Satisfied CRUD, control-centric queries#456
Conversation
There was a problem hiding this comment.
Pull request overview
This PR re-anchors “shared responsibility” data on statement-level by-components (instead of requirement-level), adds the missing read + CRUD surfaces to manage inherited/satisfied responsibilities, and introduces control-centric projections to support UI/query use-cases without upstream ssp:read.
Changes:
- Enforce statementId on export-offering item writes, validate tuple coherence, and backfill legacy
statement_idwhere derivable. - Add by-component GETs, statement-level Inherited/Satisfied CRUD, and a shared responsibility rollup endpoint.
- Add a by-control catalog endpoint and refactor leveraged-controls logic into a shared batched projection; tighten import validation and messaging.
Reviewed changes
Copilot reviewed 18 out of 20 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/service/relational/system_security_plan.go | Adds composite index for by-component parent lookup (parent_id,parent_type). |
| internal/service/migrator.go | Adds backfill migration for legacy offering items missing statement_id. |
| internal/service/migrator_test.go | Tests backfill behavior (derivable vs underivable vs dangling; idempotency). |
| internal/api/handler/oscal/system_security_plans.go | Adds/rewires SSP routes; replaces blind Save() with metadata-only updates; adds advisory lock scope and responsible-role handling utilities. |
| internal/api/handler/oscal/ssp_by_components.go | New by-component read surface + statement-level Inherited/Satisfied CRUD and leverage satisfaction resync. |
| internal/api/handler/oscal/ssp_shared_responsibility.go | New /shared-responsibility rollup endpoint (provides/inherits/satisfies/legacy). |
| internal/api/handler/oscal/ssp_shared_responsibility_test.go | Extensive unit tests covering anchoring, reads, CRUD, rollup, by-control catalog behaviors, and responsible-role m2m correctness. |
| internal/api/handler/oscal/ssp_leverage.go | Adds batched allow-list resolution, subscribe meta reporting, statement-anchored subscribe enforcement, and shared leveraged-controls projection. |
| internal/api/handler/oscal/ssp_leverage_unit_test.go | Updates unit fixtures and adds tests for created-flags and statement creation helpers. |
| internal/api/handler/oscal/ssp_leverage_test.go | Updates integration fixtures and assertions for statement-anchored subscribe materialization. |
| internal/api/handler/oscal/ssp_leverage_drift_integration_test.go | Updates drift fixture to use statement-anchored export + offering items. |
| internal/api/handler/oscal/ssp_leverage_allowlist_integration_test.go | Updates allowlist integration fixture to use coherent statement-anchored offering items. |
| internal/api/handler/oscal/ssp_export_offerings.go | Requires statementId on item writes, adds coherence validation, and adds GET /by-control/:controlId catalog projection. |
| internal/api/handler/oscal/ssp_export_offerings_test.go | Updates offering fixtures to seed coherent statement-anchored exports and item bodies. |
| internal/api/handler/oscal/import.go | Validates by-component implementation-status on import; clarifies no-op re-import messaging. |
| internal/api/handler/oscal/import_unit_test.go | Tests import validation and SSP route presence/absence contracts (no requirement-level POST). |
| internal/api/handler/oscal/import_integration_test.go | Integration tests for import rejecting invalid status and reporting no-op on existing UUID. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
gusfcarvalho
left a comment
There was a problem hiding this comment.
Findings inline (9). Verified first: CI is green 4/4, and go build / go vet / unit tests pass locally — nothing here is a ship-blocker, and I confirmed the cedar_test.go gofmt note is genuinely pre-existing on main.
Headline: the statement-anchoring work is sound and the batching discipline is genuinely good, but the PR asserts two invariants it does not fully enforce. (1) "an SSPLeverageLink never points at nothing" is enforced with a 409 on the inherited sub-resource DELETE, while deleteByComponentCascade — reached by three routes, one of them added here — deletes those same rows unconditionally, with no FK to stop it; the result is links that silently report partial and 500 on re-attest. (2) the new offering-item coherence check validates control-id case-insensitively but stores the client's casing, and Subscribe matches it exactly — so a mixed-case controlId forks a duplicate ImplementedRequirement in the downstream tree.
Two Mediums: the satisfied DELETE skips the advisory lock its CREATE takes (lost update on the link's cached Satisfaction, which the drift detector reads), and the rollup's legacy arm reports every requirement-anchored by-component rather than only those carrying shared responsibility — so any imported OSCAL SSP has its whole control implementation listed as debt. Worth reading that one alongside its test: the fixture seeds a by-component with no export and asserts the reason "requirement-anchored export", which pins the bug rather than catching it.
Rest are Low: a third satisfaction-derivation input scope in Subscribe, an unvalidated provided-uuid on the inherited POST, unauthorized downstreamSspId on ByControl, and two test gaps where assertions can't fail.
Credit where due — bulkResolveUpstreamResponsibilities scoping by (export_id, provided_uuid) rather than provided_uuid alone is a subtle correctness point handled correctly, the blind-Save and Association().Append m2m fixes are both real bugs you caught yourself, and the git notes are the best design record I've seen on a PR in this repo — several of them pre-empted questions I was about to raise.
|
Addressed the three review findings in [High] The failure mode you described is now covered by a test that asserts the exact consequences: the by-component and requirement deletes both 409, and [High] control-id casing split the downstream tree. Confirmed and fixed at the validator, as you suggested: it now adopts the requirement's canonical casing ( Also made the statement-id/control-id asymmetry deliberate rather than accidental: statement-ids are author-coined with no canonical form and [Medium] satisfied DELETE skipped the advisory lock. Correct — with only the create side locking, the lock serialized create-vs-create and nothing else, and the Verification:
|
gusfcarvalho
left a comment
There was a problem hiding this comment.
Round 2 on ff12cb4. Verified: go build, go vet, unit tests pass locally; CI green (integration still running at time of writing).
Confirmed fixed — all three verified against the code, not just the reply:
deleteByComponentCascadeorphaning links (High): guard hoisted into the cascade itself, placed before any destructive work, and all three handlers map it to 409. This is the right shape — one definition of "owned by a subscription", inherited by every path. Nice catch on checking thatDELETE /system-security-plans/:iddoesn't route through the cascade.- Control-id casing (High):
req.ControlID = requirement.ControlIdafter the fold-compare,reqtaken by pointer, soCreateItem/UpdateItemboth persist canonical bytes.TestOfferingItemStoresCanonicalControlIdCasingdrives it end-to-end — seeds a downstream that already implementsac-2, POSTs"AC-2", subscribes, asserts exactly one requirement row. That is the test I would have written. - Satisfied DELETE lock (Medium): lock taken, and the rename to
lockByComponentSubtreeWriteis the better fix — you're right that the create-only name is why it got missed.
I also want to call out TestDeletingByComponentWithHandAuthoredInheritedSucceeds: adding the negative control (the guard keys on a real link, so a hand-authored inherited still deletes freely) is what makes the 409 test meaningful rather than something that would pass if the cascade were simply broken. That is the habit that catches the next bug.
One new finding (Medium), inline: ReAttest is a third read-modify-write over the same subtree and doesn't take the lock the new doc comment now says every such writer must. It's in scope precisely because of this PR — before the satisfied CRUD landed there was no concurrent writer for it to race. Same lost-update, one line to fix.
Still open from round 1 (6): the legacy arm over-reporting (Medium — this one also needs its test fixture changed, since the fixture currently pins the bug), Subscribe's third satisfaction-derivation scope, the unvalidated provided-uuid on the inherited POST, ByControl's unauthorized downstreamSspId, and the two tests with assertions that can't fail. Happy to take a decision-and-a-doc-line on the ByControl one rather than code — I flagged it so it wouldn't be settled by omission, not because I think it must change.
gusfcarvalho
left a comment
There was a problem hiding this comment.
Round 3 on 58a8d91. One finding, inline — and it is the only thing standing between this PR and an approve.
Subscribe still does not take lockByComponentSubtreeWrite. 58a8d91 fixed ReAttest and rewrote the lock doc to enumerate all four read-modify-write writers — explicitly naming Subscribe (via resyncLeverageSatisfaction) and warning that "partial adoption is worse than useless: it serializes only the writers that opted in, while reading as though the subtree were safe." But the lock call was never added to Subscribe. lockByComponentSubtreeWrite appears exactly once in ssp_leverage.go, in ReAttest. So the code is now in precisely the state its own new comment warns about, and the thread was resolved as though both had landed.
I want to be clear that I am not re-litigating a design decision — you agreed with this one and documented it; the call site is just missing. One line after findOrCreateByComponent, as in the thread.
Everything else across all three rounds is confirmed fixed, re-read in the code rather than taken from the replies: the cascade guard and its 409s, control-id canonicalization, the satisfied CREATE/DELETE locks, the legacy arm (with its fixture corrected — that one had been pinning the bug), Subscribe's satisfaction derivation collapsing to a single scope, the inherited provided-uuid resolution, the ByControl trade-off written out as an explicit decision, and both weak tests.
Worth saying plainly: the rewritten lock doc is better than what I asked for. Enumerating every writer and naming partial adoption as the hazard is what stops the next person reintroducing this — it just needs the fourth writer to comply with it. Approving the moment that lands. Local build/vet/unit are green; CI still running.
gusfcarvalho
left a comment
There was a problem hiding this comment.
Approving 8f45bcf. All 10 findings across four rounds are fixed, and I re-read the code for each rather than trusting the replies — which mattered, since one round resolved a thread with only half the fix landed.
Verified green: go build, go vet, gofmt, unit tests locally; CI 4/4 including the full 7-minute integration suite. (The one gofmt complaint, internal/authz/cedar_test.go, I confirmed is already dirty on main and untouched here.)
The last fix is better than what I asked for. I asked for one line in Subscribe. You added it — and then made resyncLeverageSatisfaction take the lock itself, so the invariant is enforced by construction on the path three of the four writers use, rather than by everyone remembering. That is the right instinct: the reason this was half-missed twice is that "every writer must remember X" is not an invariant, it is a hope. Moving it into the function that is the read-modify-write closes the class, not just the instance.
TestEveryLeverageSatisfactionWriterTakesTheSubtreeLock is the part I want to single out. Asserting a concurrency invariant structurally, via the AST, because the advisory lock is a no-op under the sqlite unit driver and no unit test could ever observe the race — that is a genuinely good answer to "this is untestable." The failure message names the writers and restates why partial adoption is the hazard, so the next person to trip it gets the reasoning, not just a red X. I would like to see this pattern used elsewhere in the codebase.
Trajectory across the review: two Highs (a 409 guard bypassable by deleting the parent; control-id casing forking a duplicate requirement in the downstream tree), three Mediums (three separate unlocked read-modify-writes on link.Satisfaction; the legacy arm reporting every ordinary requirement-anchored by-component as debt), and five Lows. Two of those were only findable because the tests pinned the bug rather than catching it — the legacy fixture asserting a "requirement-anchored export" on a by-component with no export, and the filter test whose helper dropped the status assertion so its negative cases passed on a 500. You fixed the fixtures, not just the code, which is the harder and more honest call.
The design record in the git notes did real work here: several times it pre-empted a finding I was about to raise, and the pushback on PUT-vs-PATCH semantics was correct and well-argued. Nice piece of engineering.
|
PR approved. Marking ready for e2e. |
Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com>
gusfcarvalho
left a comment
There was a problem hiding this comment.
Findings inline (21 threads).
Headline: 1603700 broke the integration build, and CI agrees — internal/service/worker no longer compiles under the integration tag because one LeveragedAuthUUID assignment site was missed. One-line fix, but make test-integration cannot pass until it lands.
This is a re-review of the commits pushed after the approval at 8f45bcf, plus a full pass over the implementation. Scope note: the PR body still claims full make test-integration green and 0 lint issues — both were true at 8f45bcf and were invalidated by 1603700.
Beyond the blocker, three findings I would want addressed before this merges:
resyncLeverageSatisfactioncan overwrite the wrong link'sSatisfaction— it derives from one by-component's satisfied set but finds the link by(downstream_ssp_id, provided_uuid). That unique index constrains links, not inherited rows, and nothing stops a second by-component in the same SSP inheriting the same provided-uuid. Silent corruption on the drift path, bidirectional.deleteByComponentCascade's new subscription guard is TOCTOU-racy — it checks a preloaded id set but bulk-deletes byby_component_id, and never takeslockByComponentSubtreeWrite. A concurrentSubscribegets its inherited row deleted anyway, danglingSSPLeverageLink.InheritedUUIDwith no FK to catch it. This is the "partial adoption is worse than useless" case the lock's own comment argues against.- The new filter↔responsibility API ignores
Control's composite(catalog_id, id)PK in three places, so with two catalogs defining the same control id it links the wrong catalog's control and fails to unwind the link on detach.resolveCatalogIDForControlalready exists for exactly this, and the handler has the SSP id in hand.
Two of the PR's own claims do not hold up, which is why I am flagging them rather than trusting the descriptions:
updateByComponentMetadatastill zeroes omitted scalar fields. Map-formUpdateswrites every key, so the fix narrowed the blast radius from all columns to six — within those six, omitted still means zeroed. The inline comment frames that as the bug being fixed, and the swagger now publishes the stronger claim.TestUpdateByComponentDoesNotClobberSubtreesOrOmittedFieldsdoes not catch it: despite the name it only asserts the omitted subtrees survive.TestSubscribeReportsReusedRowsAsNotCreatedassertsCreated == true.Statements[].Created == falseandByComponents[].Created == falseare asserted nowhere in the PR, so a regression hardcodingcreated: truewould pass the suite.
subtree_lock_invariant_test.go deserves a second look too: as a hand-maintained allowlist it cannot fail for the sixth-writer regression it exists to catch, and the "no test can observe the lock" justification does not hold — there is a real Postgres integration suite here, and nothing drives concurrent writes against the same by-component. As it stands the invariant would still be green if the lock body were return nil.
Verified rather than assumed: go build, go vet, go test, and go test -race all pass locally; the integration break reproduces locally and matches CI's log verbatim; the gofmt regression is clean at main/8f45bcf and dirty at 1603700; the FirstOrCreate fix was probed empirically against this repo's GORM version and is correct.
What is genuinely good: the trust boundary holds throughout — ByControl evaluates ssp-export-offering:read only and never ssp:read on the upstream. No blind Save() survives in the new code. The LeveragedAuthUUID nullability change is otherwise clean and its migration is correctly ordered and idempotent. Swagger is properly regenerated. projectLeveragedControls reuse genuinely keeps the two surfaces consistent at a fixed query count. And filter_responsibilities_test.go is the cleanest test file in the PR — every zero-count assertion has a proven non-zero precondition, which is exactly the discipline the two tests above are missing.
|
Round 5 addressed in Blocker. Confirmed and fixed:
Two corrections, for the record:
One deliberate departure — filter catalog scoping (:522). I did not make a profile miss a 404. By-component PUT (:4448). Went with merge semantics rather than weakening the docs, since the swagger claim is already published — the update map is built from the keys actually present in the raw body. Note Tests. Verification. One gap, stated plainly: I could not run the integration suites locally — no Docker daemon on this machine. They compile under |
|
CI is fully green on The last outstanding failure was a staticcheck There are no open review threads and nothing further is actionable on my side. The PR still carries a |
|
Closing the verification gap from my last comment: Docker became available, so I ran what I previously could not. The new/changed suites specifically: More importantly, I verified the concurrency test is not vacuous — which was the substance of your point about the structural test. You wrote that the whole invariant would still be green if That is the lost update itself, reproduced against real Postgres: two concurrent satisfied-writes, both deriving For completeness, the discovery-based structural test was verified the same way earlier: it fails when the lock is removed from No code changed in this pass; this is verification only, so |
gusfcarvalho
left a comment
There was a problem hiding this comment.
Findings inline (14). No blockers — verified at 2fd340d: go build, go vet, go test ./internal/... and gofmt all clean, CI 4/4 green.
Headline: one High — AttachResponsibility never checks that the filter belongs to the SSP it is attached for, and because the route guards on the filter id, no permission is evaluated against the body-supplied sspId at all. The rest are Mediums around a genuine gap in the otherwise-solid lock story (the new Postgres concurrency suite does not currently fail if the lock is removed), the duplicate-inherited invariant being enforced on the hand-authored path but not on Subscribe, an N+1 hiding under the "fixed six queries" comment, and UpdateItem shipping untested.
Credit where due: I enumerated all four SSPLeverageLink.Satisfaction writers and each now takes lockByComponentSubtreeWrite inside its own transaction, with resyncLeverageSatisfaction and deleteByComponentCascade both self-locking — that invariant is complete. All three dangling-InheritedUUID delete paths are guarded, every new route carries an authz guard, and I found no nil-deref panics or IDOR. The FindInBatches rework is correct specifically because gorm uses keyset pagination there. The git notes design record made this reviewable — several things I would otherwise have flagged were already answered there, and I left them alone.
Two notes outside the diff, for information only, not asks:
internal/service/schedulertrips the race detector undergo test -race. Pre-existing and untouched by this PR — out of scope here, but worth a ticket.- I initially reviewed two commits behind:
git fetchfails silently against the SSH remote, so4f43822and2fd340dwere invisible. Four findings I had drafted were already fixed in those commits and were dropped before this review — flagging in case the same stale-tree trap is hitting other reviewers or tooling.
gusfcarvalho
left a comment
There was a problem hiding this comment.
Round 6 re-review at 5893c06. All 14 findings from the last round are genuinely fixed — I verified each against the code rather than taking the note at its word. One new Medium inline, introduced by the Subscribe fix itself.
Verified at this head: go build, go vet, full unit suite, and gofmt all clean (only the pre-existing internal/authz/cedar_test.go). CI: check-diff, lint and unit-tests green; integration-tests still running at the time of writing.
The one new issue: ensureProvidedUUIDNotAlreadyInherited is correct and correctly shared across both creation paths, but Subscribe calls it once per item, and the helper re-derives every requirement / statement / by-component id in the SSP each time — ~4N queries for an N-item subscribe, plus unbounded IN lists that run into the same 65535 bind ceiling this commit reasons about for allowedDownstreamSQL. A single correlated EXISTS fixes both; details and a suggested query are in the thread. Keeping the call inside the loop is right — hoisting it would miss two items in one request sharing a provided_uuid.
Things I want to call out positively, because they are the difference between a fix and a fixed-and-proven fix:
- The lock is now proven, not just asserted. Neutering
lockByComponentSubtreeWriteand confirmingTestConcurrentSatisfiedWritesConvergeOnProjectionfails with expectedfull/ actualpartialis exactly the evidence that finding asked for. Placing the rendezvous before the lock rather than after — because after would self-deadlock — is the correct reading of why the naive fix does not work. - The honest limit on the dangle test. Stating plainly that it is no longer vacuous but still not lock-discriminating, and pointing its doc comment at the test that is, is a better outcome than quietly claiming coverage that is not there.
- Two deviations from the guidance, both right. Ordering the ownership check after the SSP-existence check (a hoist turns a 404 into a misleading 400) and dropping the unused
excludeByComponentIDparameter. Catching the 404→400 regression by running the suite, then recording why the order matters in a comment, is the right instinct. - The out-of-scope catch was surfaced, not smuggled. The satisfied create had the same post-commit 5xx defect as the reload at :601; fixing both and flagging explicitly that the second was not in any thread is the right way to handle it.
allowedDownstreamSQLis a faithful translation ofisDownstreamAllowed, and the reasoning for correlatedEXISTSoverWHERE id IN (?)is sound — which is precisely why the Subscribe path above should follow the same principle.
Nothing else outstanding. Once the EXISTS rewrite lands and integration-tests are green, this is good to go from my side.
gusfcarvalho
left a comment
There was a problem hiding this comment.
Approving at 07f1f13. All 15 findings across rounds 6 and 7 are fixed and verified; every review thread is resolved.
Verified at this head, not taken on trust: go build -mod=readonly ./..., go vet ./..., the full unit suite, and gofmt all clean (the only gofmt-dirty file is internal/authz/cedar_test.go, which is dirty on main already and untouched here). CI 4/4 green: check-diff, lint, unit-tests, integration-tests.
The final fix is exactly right: ensureProvidedUUIDNotAlreadyInherited now walks control_implementations → implemented_requirements → statements → by_components → inherited_control_implementations in one correlated query with two bind parameters, independent of SSP size, with no CAST (the join columns are uuid on both sides, and casting would break the sqlite unit suites) and parent_type literals matching the previous walker. The call stayed inside the item loop, and byComponentIDsForSSP was moved to the test file rather than left as dead production code or silenced with a lint exclusion.
One correction I want to record, because it went the right way: I justified keeping that call inside the loop by claiming per-item evaluation is what catches two items in one request sharing a provided_uuid. That premise was wrong — Subscribe already rejects that at request level with a 400 via the seenProvidedUUIDs check (ssp_leverage.go:637), in a validation pass that runs before the item loop, and that guard predates the per-SSP invariant. The author found this by writing the test I asked for, watching it fail with 400 instead of 409, and then asserting the actual behaviour and documenting which guard catches it. Correcting a reviewer's stated reasoning rather than quietly implementing around it is the better outcome, and the resulting test is more useful than the one I asked for.
What this PR does well, across the whole review:
- Invariants are proven, not asserted. Neutering
lockByComponentSubtreeWriteand confirming the lost-update test fails with expectedfull/ actualpartialis the evidence that finding needed. Placing the rendezvous before the lock — because after would self-deadlock — is the correct reading of why the naive fix fails. - Honest about limits. Stating plainly that the dangle test is no longer vacuous but still not lock-discriminating, and pointing its doc comment at the test that is, beats quietly claiming coverage that is not there.
- Deviations from guidance were reasoned and recorded. Ordering the ownership check after the SSP-existence check (hoisting turns a 404 into a misleading 400, caught by running the suite) and dropping an unused parameter.
- Out-of-scope catches were surfaced, not smuggled. The satisfied create carrying the same post-commit 5xx defect as the reload was fixed and explicitly flagged as not having been in any thread.
- The
git notesdesign record made this reviewable at a scale — 12.6k additions — where it otherwise would not have been.
Nothing outstanding from my side.
|
PR approved. Marking ready for e2e. |
Makes the statement the canonical anchor for everything carrying a sense of shared responsibility. Every
export,inheritedandsatisfiednow hangs off a statement-level by-component. Requirement-level by-components become legacy: readable, deletable, migratable — never newly creatable.A control is too coarse to attribute responsibility against. Anchoring on the statement lets us say exactly which clause the upstream discharges and which the downstream must — the whole point of the export → inherit → satisfy loop.
Part 1 — The statement is the anchor
statementIdis required on offering item create/update (400 with a message that explains why).(controlId, statementId, componentUuid, providedUuid)tuple must actually resolve to one real statement-anchored by-component inside this SSP —Provided → Export → ByComponent(parent_type="statements") → Statement → ImplementedRequirement → ControlImplementation. Incoherent tuples → 400.migrateBackfillOfferingItemStatementIDs): walksprovided_uuid → export → by_componentfor legacystatement_id IS NULLrows and derives the statement where it can. Requirement-anchored and dangling rows keep NULL — both counts are logged. The column stays nullable: statement-anchoring is a write-path constraint, not a schema change, so legacy rows stay readable and deletable.Subscribealways statement-anchors. A legacy NULL-statement item → 422 naming the item, instead of silently falling back to requirement-anchoring (which is what produced by-component rows the API could never delete).Subscribereports what it materialized, in the response envelope's existingmetafield —datastays exactly theSSPLeverageLink[]every current caller reads:Part 2 — By-component reads (there were none, at either level)
GETfor a single by-component (both levels), the list on a statement, and theexport/provided+export/responsibilitiescollections. The single-by-component GET is deep-preloaded — export (provided + responsibilities), inherited, satisfied, responsible-roles, implementation-status — so the UI can refetch one by-component after editing a sub-resource.Added the missing composite index on
by_components(parent_id, parent_type). Every by-component resolution was an unindexed scan, and the new read models hammer it.Part 3 — Inherited / Satisfied CRUD (the biggest hole)
Statement-level only. These had zero handlers — they were written only by
Subscribe, so a downstream author could not mark a responsibility satisfied later, edit a description, attach responsible-roles, or drop a stale inherited entry without re-subscribing.Leverage bookkeeping is kept honest:
DELETE .../inherited/:idwhere anSSPLeverageLinkreferences it → 409 (owned by its subscription; unsubscribe instead). Hand-authored entries delete freely.POST/DELETE .../satisfiedre-derive the owning link'sSatisfactionvia the existingderiveSatisfaction, in the same transaction — so partial ⇄ full flips atomically with the write that causes it and the drift detector never reads stale bookkeeping.PUTon either is metadata-only:providedUuid/responsibilityUuidare immutable (400) — they are the identities the link and drift detector join on.POST .../satisfiedvalidates the responsibility-uuid resolves to a responsibility on an export this by-component actually inherits from, scoped by(export_id, provided_uuid).Part 4 — Close the requirement-level hole without blessing it
Added
DELETE .../implemented-requirements/:reqId/by-components/:bcId(fulldeleteByComponentCascade) purely so legacy rows can be wound down. No requirement-levelPOST— pinned by a test asserting the route does not exist. All 15 requirement-level operations are marked@Deprecatedin swagger, pointing at the statement-level equivalents.Part 5 — Control-centric queries
GET /oscal/ssp-export-offerings/by-control/:controlId→ every published offering item for a control, with offering / upstream SSP / component / provided / responsibilities all resolved server-side. Honours the same trust boundary as the flat catalog:ssp-export-offering:readonly, neverssp:readon the upstream. Optional?downstreamSspId=filters to offerings that SSP may actually subscribe to.GET /oscal/system-security-plans/:id/shared-responsibility→provides/inherits/satisfies/legacy, flattened and statement-keyed. Optional?controlId=.The
inheritsarm reuses theLeveragedControlsprojection (extracted toprojectLeveragedControls), so satisfaction is derived in exactly one place and the two surfaces cannot disagree — a fixed ~6 queries regardless of link count, not an N+1.No new authz actions.
Two latent bugs fixed
Savein both by-component PUTs. Theydb.Save()-ed a struct rebuilt byUnmarshalOscalfrom the request body: every omitted field was zeroed, and any nestedexport/inherited/satisfiedin the payload was upserted as a GORM association with no cascade cleanup, diverging fromdeleteByComponentCascadeand bypassing the leverage bookkeeping the sub-resource routes enforce. Now metadata-only; nested subtrees in the body are ignored (not rejected — a client round-tripping a GET into a PUT must not start failing).POST /api/oscal/importbypassed validation and lied about no-ops. It never calledvalidateByComponentImplementationStatus, so a badimplementation-statussailed into the tree; andFirstOrCreatekeyed on the OSCAL UUID silently writes nothing when re-importing a changed SSP under an existing UUID, while reporting "Successfully imported". Now validated at both anchoring levels, and the no-op says so plainly. (Successstaystrue— flipping it would turn an idempotent re-import into a 400.)A third bug, caught by self-review
Writing the responsible-roles test the spec asked for exposed a live bug in this PR's own code:
Association("ResponsibleRoles").Append(roles)writes the role rows but does not cascade into each role'sPartiesmany2many — everyresponsible_role_partiesjoin row was silently never written. Switched totx.Create(&roles)with an explicit parent_id/parent_type (derived from the parsed GORM schema, not hardcoded), the same cascade path the nested by-component create already relies on.Tests
~30 new tests. Statement anchoring (missing statementId → 400; each incoherent tuple → 400; NULL-statement subscribe → 422; backfill derives / leaves NULL / is idempotent). Subscribe's
createdblock (inserted vs reused rows; tree is never requirement-anchored). By-component reads. The blind-Saveregression (a PUT omitting the subtrees leaves them intact; a PUT carrying a nested export does not upsert it). Inherited/Satisfied round-trips, immutable uuids, 409 on subscription-owned inherited, satisfaction re-derivation partial ⇄ full. Responsible-roles + their m2m join rows across create/update/delete, with the shared Party surviving. Requirement-level cascade delete; no requirement-level POST route. Import validation + honest no-op. Both new queries, incl. case-folded control matching, allow-list filtering, and a router-level test thatby-controlisn't shadowed by/:id.Existing leverage/offering fixtures were migrated from requirement-anchored to statement-anchored exports — not incidental churn: an offering item whose
providedUuidwas invented out of thin air is exactly the incoherent tuple the new coherence check rejects.Verification
go build ./...·go vet ./...andgo vet -tags integration ./internal/...·make test· fullmake test-integration(incl.TestSSPLeverageIntegrationSuite,TestSystemSecurityPlanApiIntegrationSuite,TestSubscribeAllowlistIntegrationSuite,TestLeverageDriftIntegrationSuite, and a newTestImportApiIntegrationSuite) ·golangci-lint run→ 0 issues · swagger regenerated (docs/committed). All green.