Skip to content

fix(vault): refuse a product group that contradicts its payload - #368

Merged
LKSNDRTMLKV merged 1 commit into
mainfrom
fix/product-group-must-match-its-payload
Sep 17, 2026
Merged

LKSNDRTMLKV merged 1 commit into
mainfrom
fix/product-group-must-match-its-payload

Conversation

@LKSNDRTMLKV

Copy link
Copy Markdown
Member

Closes #360.

A create body could carry an explicit productGroup and a productGroupData whose internal tag said something else, and nothing compared them. The two are read by different things — the explicit value becomes the stored product_group and picks schemaVersion, the payload is schema-validated against its own tag — so both halves passed independently.

Why it is a disclosure defect rather than a tidiness one

publish filters the public view through ProductGroupAccessPolicy::for_schema_version(passport.product_group, …) — the label. Handed a coherent-but-wrong label it resolves perfectly, so audience_view's fail-closed backstop never fires: that guard keys on the policy failing to resolve, and a real product group at its real schema version is not that case.

Every payload field the label's table does not name then falls to default_disclosure, which is Public. Battery's table names neither svhcSubstances nor disassemblyInstructions; textile's marks both restricted. So textile data stored under a battery label publishes the REACH Art. 33 substance declarations into the public view — and signs them into publicJwsSignature, where a published passport cannot be edited, only superseded.

🚨 There were two doors, not one

The issue listed the update route as a question to check. It is a real second door, and a wider one.

PATCHABLE_FIELDS contains productGroupData and does not contain productGroup. So PUT /dpp/{id} on a draft could swap in another product group's payload while the protected label stayed exactly as it was — reaching the same mislabelled state by a route that never names a product group at all. A create-side check alone would have closed the front door and left this one open.

Both are closed here:

  • validate_create_request refuses when both declarations are present and disagree, naming each so the caller can see which to change. Refused rather than reconciled: either choice silently discards a declaration the caller made, and this is a create, so no existing record's meaning changes by rejecting.
  • apply_patch refuses when a patched payload's group differs from the passport's stored group. Refusing rather than re-labelling, because product_group is protected precisely so it is not a caller's to change after creation — quietly moving it here would route around that protection on the one path that does not mention it.

Tests

Four on create, one on the patch door.

The create four pin that the rule is about a contradiction, not about requiring both fields: a battery label over textile data is refused; agreement is accepted; payload-only is accepted (the label is derived from it, which is the documented way); label-only is accepted.

a_patch_may_not_change_the_product_group_under_the_label drives apply_patch directly and also asserts the label is untouched afterwards — a refusal that half-applied would be its own defect.

Not in this branch

The issue also asks for a query over odal.passport comparing the column against doc->'productGroupData'->>'productGroup', to find any record already in this state. That is an operational check rather than a code change, and with no deployments holding data there is nothing to sweep yet; worth running before there is.

just check green.

@LKSNDRTMLKV
LKSNDRTMLKV merged commit 948a544 into main Sep 17, 2026
14 checks passed
@LKSNDRTMLKV
LKSNDRTMLKV deleted the fix/product-group-must-match-its-payload branch September 17, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

productGroup and productGroupData may disagree, and the label picks the disclosure table

1 participant