Skip to content

feat: DRM and Constraint Catalog binding in ZeroID tokens (#59)#151

Open
safayavatsal wants to merge 4 commits into
highflame-ai:mainfrom
safayavatsal:feat/drm-constraint-catalog-binding-59
Open

feat: DRM and Constraint Catalog binding in ZeroID tokens (#59)#151
safayavatsal wants to merge 4 commits into
highflame-ai:mainfrom
safayavatsal:feat/drm-constraint-catalog-binding-59

Conversation

@safayavatsal

Copy link
Copy Markdown
Contributor

Closes #59.

Summary

  • Binds two governance artifacts -- a versioned Decision-Rights Matrix (DRM) and an ES256-signed Constraint Catalog snapshot -- into every delegation token by SHA-256 hash, so post-hoc audit can answer "which governance version authorized this token?".
  • token_exchange (RFC 8693) now refuses delegations not permitted by the active DRM; authorization_code embeds the hashes for audit but does not gate (consent is not delegation).
  • Every code path is no-op for tenants that haven't published a DRM/catalog -- pre-feat: Decision-Rights Matrix and Constraint Catalog binding in ZeroID tokens #59 flows are bit-identical, so this is opt-in per tenant.

Changes

Schema (migration 014)

  • decision_rights_matrix table -- append-only via drm_block_mutation trigger; new versions = new rows.
  • constraint_catalog_versions table -- multiple rows can share hash when the 24h re-sign produces an unchanged document (only signed_at/signature differ).
  • issued_credentials.drm_hash and constraint_catalog_hash columns + partial indexes scoped to non-revoked, non-expired credentials for policy_drift fan-out.

Domain (domain/)

  • DRMDocument, DRMAllowedDelegation, DecisionRightsMatrix.
  • ErrDRMUnauthorized, ErrDRMInvalid sentinels (wrapped with %w so callers use errors.Is).
  • ConstraintCatalogVersion with json.RawMessage document (opaque -- ZeroID hashes and signs, does not parse).
  • SignalTypePolicyDrift added to the SignalType enum and Valid() switch.
  • Governance hash fields added to IssuedCredential.

Service (internal/service/governance.go)

  • HashSHA256 -- deterministic canonical-JSON via recursive sorted-key encoding; identical documents always hash identically.
  • AuthorizeDelegation -- exact match plus single trailing-* SPIFFE pattern (predictable failure modes; no third-party glob library).
  • PublishDRM / PublishCatalog / ResignCatalog -- catalog ES256-signs sha256(hash || "|" || signed_at) via the existing jwksSvc.
  • emitDriftSignals -- best-effort fan-out of policy_drift signals on hash transition.

Worker (internal/worker/catalog_signer.go)

  • 24h re-sign tick; preserves Hash, rewrites SignedAt/Signature. Decoupled from the service package via a CatalogResigner interface.

OAuth wiring (internal/service/oauth.go)

  • tokenExchange runs resolveGovernance after scope-intersection and before IssueCredential; rejection wraps ErrDRMUnauthorized as invalid_grant.
  • authorization_code embeds the four governance claims but skips the authorization gate.
  • The four claim names (drm_version, drm_hash, constraint_catalog_version, constraint_catalog_hash) added to reservedClaims so deployer enrichers cannot spoof them; IssueCredential writes them after CustomClaims as defence-in-depth.
  • Introspect passes the four claims through when present.

Admin API (internal/handler/governance.go)

  • POST/GET/list /governance/decision-rights-matrix
  • POST/GET /governance/constraint-catalog/active
  • Routes self-suppress in registerGovernanceRoutes when governanceSvc is nil.

Tests (tests/integration/governance_test.go)

  • Happy path with claim assertions (DRM permits, JWT carries the four claims, introspection surfaces them).
  • DRM-deny path (pair not covered by DRM -> invalid_grant).
  • No-config backward-compat path (no DRM published -> exchange succeeds, no governance claims on the token).
  • Each test uses a fresh tenant via X-Account-ID/X-Project-ID headers because the DRM table is append-only (cannot t.Cleanup delete).

Test plan

  • go build ./... clean
  • go vet ./... clean
  • gofmt -l clean
  • Governance integration tests: 3/3 pass
  • Full integration suite: all pre-existing tests still pass (~44s with real Postgres via testcontainers)

Acceptance criteria

  • DRM schema defined and documented (domain/decision_rights_matrix.go)
  • DRM storage and versioning implemented (migration 014 + append-only trigger)
  • Constraint Catalog hash computed and stored on publish (PublishCatalog)
  • drm_hash and constraint_catalog_hash claims added to delegation token issuance
  • Token issuance enforces DRM authorization check (tokenExchange)
  • Introspection includes DRM/Constraint Catalog claims
  • policy_drift CAE signal implemented (SignalTypePolicyDrift + emitDriftSignals)
  • Audit archive records DRM version per token issuance event -- deferred (audit payload is already arbitrary JSON, no schema change needed; can be added in a follow-up that wires the audit emitter on the issuance path)
  • Documentation updated (route docs in internal/handler/governance.go; downstream Credential Policies guide can land separately)

Notes for reviewers

  • authorization_code deliberately skips the DRM gate. The issue says "Token issuance MUST fail if the requested delegation is not authorized by the current DRM"; my reading is that human consent is not a delegation in the SPIFFE-pair sense (no from -> to URI pair exists), so the hash binding is the right answer there but the gate is not. Happy to revisit if you read it differently.
  • Cedar is not in-tree on main; the issue references "Cedar policy evaluation in Shield" which I read as downstream. The Constraint Catalog is therefore an opaque blob ZeroID hashes and signs but does not parse, matching the "machine-readable, versioned policy document" wording.
  • DRM from/to SPIFFE pattern matching supports only exact match and a single trailing *. Full glob is over-engineering for an operator-authored DRM; predictable failure modes matter more than expressiveness.

…ai#59)

- Migration 014: append-only decision_rights_matrix (DB trigger blocks
  UPDATE/DELETE), constraint_catalog_versions (Hash can repeat across
  rows on 24h re-sign), and drm_hash + constraint_catalog_hash columns
  on issued_credentials with partial indexes for drift detection.
- Domain types: DRMDocument/DecisionRightsMatrix (+ ErrDRMUnauthorized
  and ErrDRMInvalid sentinels), ConstraintCatalogVersion, and
  SignalTypePolicyDrift.
- GovernanceService: deterministic canonical-JSON hashing (sorted keys
  recursively), DRM authorization with exact and trailing-* SPIFFE
  pattern matching, catalog publish/re-sign with ES256 signing over
  sha256(hash||"|"||signed_at). policy_drift signals fan out from
  PublishDRM/PublishCatalog on hash change.
- CatalogSignerWorker re-signs each tenant's active catalog every 24h
  (preserves Hash, rewrites SignedAt+Signature so outstanding tokens
  stay valid).
- OAuthService.tokenExchange runs the DRM authorization gate before
  IssueCredential and embeds drm_version/drm_hash/
  constraint_catalog_version/constraint_catalog_hash claims.
  authorization_code embeds the claims for audit but skips the gate
  (consent is not delegation). All four claim names added to
  reservedClaims; Introspect passes them through.
- Admin endpoints under /governance/decision-rights-matrix and
  /governance/constraint-catalog. Routes self-suppress when
  governanceSvc is nil. Every code path is no-op for tenants that
  haven't published a DRM/catalog row -- pre-highflame-ai#59 flows are unchanged.
- Integration tests: happy path with claim assertions, DRM-deny path,
  and no-config backward-compat path. Each test uses a fresh tenant
  to keep the append-only DRM rows from leaking across the run.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a governance framework featuring a Decision-Rights Matrix (DRM) and a Constraint Catalog to bind policy snapshots to issued credentials. Key additions include the GovernanceService for managing these artifacts, new API endpoints for publishing and retrieving them, and a background worker for periodic catalog re-signing. Feedback highlights several critical improvements: the emitDriftSignals function should perform asynchronous fan-out with pagination to prevent blocking API responses and OOM risks; the canonicalEncode logic can be simplified using standard library features; and database errors in PublishDRM and PublishCatalog must be handled to ensure reliable policy drift detection. Additionally, it is recommended that the CatalogSignerWorker perform an initial run at startup to prevent stale signatures in environments with frequent restarts.

Comment thread internal/service/governance.go Outdated
Comment thread internal/service/governance.go Outdated
Comment thread internal/service/governance.go Outdated
Comment thread internal/service/governance.go Outdated
Comment thread internal/worker/catalog_signer.go Outdated
Conflicts resolved across domain types, OAuth service, handler API
surface, and server wiring. All conflicts were textual (both sides
added orthogonal fields/code in adjacent regions); semantic intent
of each side is preserved.

- domain/credential.go: kept both DRMHash/ConstraintCatalogHash
  (highflame-ai#59) and MissionID (highflame-ai#81) fields.
- domain/signal.go: kept both SignalTypePolicyDrift (highflame-ai#59) and
  SignalTypeIdentityExpired in const block and Valid() switch.
- internal/service/credential.go: kept both governance binding
  fields (DRMVersion/DRMHash/ConstraintCatalogVer/ConstraintCatalogHash)
  and MissionID/CredentialExpiresAt fields on IssueRequest; merged
  IssuedCredential row literal accordingly.
- internal/service/oauth.go: kept governanceSvc + backchannelSvc as
  separate fields. tokenExchange's resolveGovernance now uses jwx v4
  delegatedBy var (Subject() returns (string, error) in v4) and
  threads MissionID. authorization_code adds IdentityPolicyID
  alongside governance hashes. Introspect passthrough uses
  jwt.Get[string] instead of v2 Get.
- internal/handler/routes.go: combined attestationPolicySvc +
  backchannelSvc + governanceSvc fields and NewAPI params. Admin
  route registration adds registerGovernanceRoutes after
  registerBackchannelAdminRoutes/registerExpiringSoonRoute.
- server.go: kept both backchannelSvc wiring (CIBA two-phase) and
  governanceSvc wiring; cleanupWorker now takes backchannelRepo per
  upstream signature; handler.NewAPI call updated; gofmt applied to
  the merged struct literal.
- Migration renumber: 014_governance_artifacts -> 023_governance_artifacts
  to avoid prefix collision with upstream's 014_attestation_policies.

Verified clean: GOEXPERIMENT=jsonv2 go build, go vet, gofmt -l,
governance integration tests (3/3 pass), full integration suite
(green, ~9s with the upstream test optimisations).
Five comments resolved in priority order:

(high, #3258137060) emitDriftSignals fan-out now runs on a detached
goroutine parented on a new GovernanceService.svcCtx so a hash
transition affecting many identities does not block the admin POST.
Server.Shutdown calls GovernanceService.Stop() to cancel in-flight
fan-outs (mirrors the BackchannelService pattern). The affected-
identity scan is paginated via the new
CredentialRepository.ListIdentitiesByGovernanceHashPage (keyset
cursor on identity_id ASC, default page size 500) so a single drift
event cannot OOM the worker on huge tenants.

(medium, #3258137067) canonicalJSON drops the hand-rolled recursive
encoder. encoding/json.Marshal sorts string-keyed map keys, so the
two-pass Marshal->Unmarshal-into-any->Marshal pattern produces the
same canonical output via stdlib alone. Hashing tests still pass —
the determinism contract is preserved.

(medium, #3258137081, #3258137088) PublishDRM and PublishCatalog
now log the GetActive lookup error instead of swallowing it. The
failure is still non-fatal (we don't want to fail the write because
the drift lookup couldn't reach the DB) but is no longer silent.

(medium, #3258137107) CatalogSignerWorker performs an initial run
at startup before entering the 24h tick loop so a server restarted
more often than the re-sign interval still produces fresh signed_at
rows.

Verified clean: GOEXPERIMENT=jsonv2 go build, go vet, gofmt -l,
governance integration tests (3/3 pass), full integration suite green.
Resolved conflicts:
- internal/handler/routes.go: kept both registerGovernanceRoutes (from PR) and registerSigningCredentialRoutes (from upstream)
- server.go: kept both governanceSvc and signingCredSvc parameters in NewAPI call

This integrates the signing credential feature (highflame-ai#150) with the governance binding feature (highflame-ai#59).

Co-authored-by: Cursor <cursoragent@cursor.com>
@safayavatsal

safayavatsal commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

Merge Conflicts Resolved

Successfully merged upstream/main into this branch to resolve the merge conflicts.

Conflicts Resolved:

  1. internal/handler/routes.go (lines 136-141)

    • HEAD (PR): Added a.registerGovernanceRoutes(api) for DRM/Constraint Catalog binding
    • upstream/main: Added a.registerSigningCredentialRoutes(api) for signing credentials (feat: workload-attested ephemeral signing credentials #150)
    • Resolution: Kept both route registrations to integrate both features
  2. server.go (lines 243-250)

    • HEAD (PR): Added governanceSvc parameter to NewAPI() call
    • upstream/main: Added signingCredSvc parameter and reformatted to two lines
    • Resolution: Kept both governanceSvc and signingCredSvc parameters in the two-line format

New Features Integrated from upstream/main:

The merge conflict resolution is complete and the branch is now up-to-date with main.

cc: @rsharath

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.

feat: Decision-Rights Matrix and Constraint Catalog binding in ZeroID tokens

1 participant