Skip to content

feat: admin-consent status GET + ConsentGrant provenance - #1945

Open
serguei-gorokhov wants to merge 5 commits into
rd/5-mint-sites-hardeningfrom
rd/6-consent-status
Open

serguei-gorokhov wants to merge 5 commits into
rd/5-mint-sites-hardeningfrom
rd/6-consent-status

Conversation

@serguei-gorokhov

Copy link
Copy Markdown

Applicable issues

Description of changes

GET /v1/consent/{deployment_id}/admin-consent completes the verb set — the Admin Panel can show consent state without mutating (grant and withdraw were the only observers before). Gated exactly like the writers: hasExplicitAdminAccess before any resolution (fail-closed on unconfigured admin rules; a per-request key can never call it), 404 on unknown deployment, and a non-declaring app is a legitimate {"consented":false} answer — reads inform, the writers' 400 is a semantic-error guard.

The stored record becomes a ConsentGrant envelope — the approved snapshot plus server-stamped provenance (grantedBy from the authenticated admin context, grantedAt from the injectable clock) — same record, same key, same ADMIN_CONSENT type, no new storage. The provenance sits outside the client-echoed Consent document, so nothing stampable rides the GET→POST round-trip and the content-binding compare (on the envelope's consent section) is untouched.

Tri-state semantics, load-bearing: consented means live right now — a record exists AND its snapshot equals the current declaration — exactly what the request-time gate enforces; the GET and the resolver share one describeAdminConsent implementation and cannot drift. A stale record reports consented:false, stale:true with the last approval's provenance and snapshot still visible for the panel's re-approve view; withdrawn or never-granted reports a clean consented:false. The user-consent path (acceptConsent, buildConsent, verifyUserConsent, USER_CONSENT storage) shows zero diff — its who/when are structural.

The story ships as five commits; three follow-ups are part of this PR's intended diff:

  1. 2b23fb40legacy-record leniency (security review): ConsentGrant ignores unknown properties (the stored-DTO convention — Conversation, Publication, …), so a record written by the pre-envelope commits of this stack — a bare Consent body at the same key — deserializes with consent == null and flows into the already-built fail-closed path (stale, nothing resolves, withdrawal works) instead of 400-ing every user call, 500-ing the GET, and making a legacy record un-withdrawable. Pinned by a unit test feeding the legacy body shape through describeAdminConsent.
  2. 1b4cb8easingle-sourced gate predicate (code review): isAdminConsented no longer delegates through the presentation DTO — the request-time gate and the status read call one zero-allocation matches(stored, declaration) predicate: nothing drifts, no DTO on the per-request hot path, and gate semantics can never silently inherit presentation-driven changes. The provenance-non-participation test was vacuous (the stub never varied); it now re-stubs with a different grantedBy/grantedAt over the same snapshot and asserts the yes stands.
  3. 402836d5 + 81a2b610OpenAPI handling (docs pair): the tri-state contract is stated in the annotation response description and JavaDoc; a hand-edit of the committed spec was added then deliberately reverted — the committed OpenAPI spec stays purely generator-owned (the team's practice since the automation pipeline). Pipeline gotcha flagged for the team: because description is a MANUAL_PREFERRED_FIELD, changing an annotation's response description never propagates to a node that already carries one — annotation descriptions only flow for newly added nodes.

Behavior changes flagged for review

  • New admin-only GET /v1/consent/{deployment_id}/admin-consent; tri-state response; non-declaring app returns 200 {"consented":false} (not 400 like the writers — deliberate: reads inform).
  • Stored record body changes shape (bare Consent → ConsentGrant envelope). Legacy bare-Consent records read leniently and fail closed.
  • Whole-declaration, all-or-nothing granularity is stated, not hidden: a change to any one dependency invalidates consent for all of them.

Checklist

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

🤖 Generated with Claude Code

Serguei Gorokhov and others added 5 commits September 3, 2026 13:22
GET /v1/consent/{deployment_id}/admin-consent completes the verb set —
the Admin Panel can now show consent state without mutating (grant and
withdraw were the only observers before). Gated exactly like the
writers: hasExplicitAdminAccess before any resolution (fail-closed on
unconfigured admin rules; a per-request key can never call it), 404 on
unknown deployment, and a non-declaring app is a legitimate
{"consented":false} answer — reads inform, the writers' 400 is a
semantic-error guard.

The stored record becomes a ConsentGrant envelope — the approved
snapshot plus server-stamped provenance (grantedBy from the
authenticated admin context, grantedAt from the injectable clock) —
same record, same key, same ADMIN_CONSENT type, no new storage. The
provenance sits outside the client-echoed Consent document, so nothing
stampable rides the GET->POST round-trip and the content-binding
compare (on the envelope's consent section) is untouched.

Tri-state semantics, load-bearing: consented means live right now — a
record exists AND its snapshot equals the current declaration — exactly
what the request-time gate enforces; the GET and the resolver share one
describeAdminConsent implementation and cannot drift. A stale record
reports consented:false, stale:true with the last approval's provenance
and snapshot still visible for the panel's re-approve view; withdrawn
or never-granted reports a clean consented:false. The user-consent
path (acceptConsent, buildConsent, verifyUserConsent, USER_CONSENT
storage) shows zero diff — its who/when are structural (the record's
bucket is the consenting user; the blob metadata is the when).

Spec: documentation repo, offline-access-delegation/implementation-specs/pr3b-admin-consent-status.md

Co-Authored-By: Claude Code <noreply@anthropic.com>
Security-review follow-up to the status-GET commit: ConsentGrant
deserialized the stored admin-consent record with the strict mapper
(FAIL_ON_UNKNOWN_PROPERTIES is the codebase default), so a record
written by the pre-envelope commits of this branch — a bare Consent
body at the same key — threw on every read: the resolver 400'd every
user call to a declaring app, the new status GET 500'd, and withdraw
threw before the delete, making a legacy record un-withdrawable except
by re-grant.

ConsentGrant now ignores unknown properties (the stored-DTO convention
— Conversation, Publication, …): a legacy body deserializes with
consent==null and flows into the already-built fail-closed path —
stale, nothing resolves, withdrawal works. Pinned by a unit test
feeding the legacy body shape through describeAdminConsent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ticipation

Code-review follow-up to the status-GET commit:

- isAdminConsented no longer delegates through the presentation DTO —
  the request-time gate and the status read now call one zero-allocation
  matches(stored, declaration) predicate: nothing drifts, no DTO on the
  per-request hot path, and gate semantics can never silently inherit
  presentation-driven changes to describeAdminConsent.
- The 'provenance never participates in the binding' test assertion was
  vacuous (the stub never varied); it now re-stubs with a different
  grantedBy/grantedAt over the same snapshot and asserts the yes stands.
- The GET's 200-response description in the OpenAPI annotations now
  states the tri-state contract (consented = live right now, exactly
  what resolution enforces; stale keeps the last approval's provenance
  and snapshot) — the annotation channel is the repo's endpoint-doc
  mechanism (the custom ApiOperation has no operation-level description
  attribute).

Co-Authored-By: Claude Code <noreply@anthropic.com>
…I response description

The SpecMerger deliberately preserves committed-spec descriptions over
skeleton regeneration (description is a MANUAL_PREFERRED_FIELD), so the
response-description slot is the designed home for endpoint contract
prose — the custom ApiOperation annotation has no operation-level
description attribute. Verified the description survives a full
replaceSpec run.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Reverts the hand-edited response description from the previous commit.
The SpecMerger's MANUAL_PREFERRED_FIELDS precedence means a committed
description is a stable fixed point across regenerations, so the edit
was mechanically safe (checkOpenApiDiff stays green) — but the team's
practice since the automation pipeline is that the spec is not touched
manually; the precedence rule is migration scaffolding freezing
first-generation strings, not an active hand-curation channel. The
tri-state contract lives in the implementation spec, the JavaDoc, and
the annotation description (which also documents intent in code).

Pipeline gotcha worth knowing, flagged for the team: because
description is manual-preferred, changing an annotation's response
description never propagates to a node that already carries one —
annotation descriptions only flow for newly added nodes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
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.

1 participant