Skip to content

Accept ODRL SACD grants under DIMO profile v1, with per-permission data windows - #17

Open
elffjs wants to merge 4 commits into
mainfrom
odrl-sacd-v1
Open

Accept ODRL SACD grants under DIMO profile v1, with per-permission data windows#17
elffjs wants to merge 4 commits into
mainfrom
odrl-sacd-v1

Conversation

@elffjs

@elffjs elffjs commented Jul 9, 2026

Copy link
Copy Markdown
Member

What

Alongside legacy SACDData documents, the exchange now accepts CloudEvents of type dimo.sacd.odrl whose data is a W3C ODRL 2.2 Agreement restricted to DIMO profile v1 (https://ns.dimo.co/odrl/v1). Dispatch is on the CloudEvent envelope type; legacy documents flow through the existing path untouched.

ODRL: W3C spec · Wikipedia

Profile v1 covers the capabilities of legacy permission grants plus one extension beyond them: per-permission data windows.

Concept ODRL term
Asset / subject target (ERC721/Ethr DID)
Grantor assigner (did:ethr:...)
Grantee assignee (did:ethr:...)
Valid period constraint (dateTime comparisons, conjunctive; absent = unbounded)
Named permissions permission[].action (existing privilege:* names)
Data window permission[].constraint (dimo:recordedAt comparisons, conjunctive)

Parties are DIDs rather than legacy {address} objects. CloudEvent-scoped access is not in profile v1; token requests carrying event filters are refused when backed by an ODRL grant.

Data windows (dimo:recordedAt)

A permission may be granted only for data recorded within a time window — "you can watch the car live and read Q2 2026 history, but nothing else":

{ "action": "privilege:GetLocationHistory",
  "constraint": [
    { "leftOperand": "dimo:recordedAt", "operator": "gteq", "rightOperand": "2026-04-01T00:00:00Z" },
    { "leftOperand": "dimo:recordedAt", "operator": "lt",   "rightOperand": "2026-07-01T00:00:00Z" } ] }

The two constraint positions answer different questions and accept disjoint vocabularies. Policy-level dateTime bounds when the grant may be exercised — evaluated once, at exchange time, never in the token. Per-permission dimo:recordedAt (profile-defined) bounds the recording timestamps of readable data — not evaluated at exchange time, but forwarded verbatim into the minted token for the data services to enforce.

One constraint language end to end: the atom {leftOperand, operator, rightOperand} is a shared type in pkg/tokenclaims, used identically in grant documents and token claims. The exchange verifies and consumes the policy envelope (parties, target, validity, signature) and forwards the surviving data-level atoms untouched — selection and resolution, never translation. pkg/tokenclaims also ships the canonical fail-closed evaluator (RecordedAtWindow, AllowsInterval, AllowsAt) for consumers.

The claim encoding is fail-closed by construction

A permission granted under constraints is minted only into the new scoped_permissions claim — never the flat permissions array, never the deprecated privilege_ids:

"permissions": ["privilege:GetNonLocationHistory"],
"scoped_permissions": [
  { "name": "privilege:GetLocationHistory",
    "constraint": [
      { "leftOperand": "dimo:recordedAt", "operator": "gteq", "rightOperand": "2026-04-01T00:00:00Z" },
      { "leftOperand": "dimo:recordedAt", "operator": "lt",   "rightOperand": "2026-07-01T00:00:00Z" } ] } ]

A consumer that reads only the flat claims sees no permission at all, so it cannot honor a windowed grant while ignoring its window — every deployed consumer today refuses scoped tokens with zero changes. If a consumer forgets to enforce a future constraint type, that degrades to "windowed tokens don't work there" (a loud functional bug), not a silent authorization hole. The gRPC AccessCheck, whose response cannot express constraints, reports has_access: false for scoped grants for the same reason.

Unconditional grants mint claims identical to an equivalent legacy document — no scoped_permissions key at all — so existing consumers are unaffected until a grantor actually authors a windowed agreement.

Companion: dq's scoped-permissions branch adds the enforcement: ranged queries (signals/events/segments/cloud events) reject ranges outside the window; latest-value queries are evaluated within it.

Strictness rules

  • Unknown fields anywhere (prohibition, duty, refinements) reject the document in full.
  • No JSON-LD processing, ever. @context must be byte-identical to a canonical form and is never dereferenced — a hostile context cannot remap term meanings under the grantor's signature.
  • Actions come from a closed vocabulary (the existing permission names); an unknown action rejects the whole document so authoring typos fail loudly at exchange time.
  • Duplicate actions reject the document: under ODRL, two entries naming the same action are independent grants — a union (e.g. two disjoint data windows) — and v1 defines no union semantics, so no entry is silently preferred over another. A later version may define the union reading.
  • Operators limited to gteq/gt/lteq/lt with plain RFC 3339 operands; policy-level constraints accept only dateTime, per-permission constraints only dimo:recordedAt — exercise time and data time never mix positions.

Later profile versions widen the vocabulary (CloudEvent scoping via refinements, Set templates, isAnyOf, purpose constraints, further data-level operands like geofences); they never change the meaning of v1-valid documents, and consumers automatically refuse operands they haven't been taught.

Files

  • pkg/tokenclaims/constraint.go — shared constraint atom + canonical fail-closed window evaluator
  • pkg/tokenclaims/tokenclaims.goscoped_permissions claim
  • internal/exchange/models/odrl.go — strict profile parser and structural validation (both constraint positions)
  • internal/exchange/autheval/odrl.go — grant-map evaluation (target, validity window, actions, per-permission constraints)
  • internal/exchange/services/access/access.go — envelope-type dispatch + evaluateODRLDoc; ValidateAccess now returns a Decision carrying scoped grants
  • internal/exchange/services/signer.go — fail-closed claim split at minting
  • internal/exchange/controllers/rpc/rpc.goAccessCheck fail-closed on scoped grants
  • internal/exchange/services/ipfs_service.go — accept the new envelope type
  • docs/odrl-profile-v1.md, docs/odrl-profile-v1.schema.json — profile spec, example document, JSON Schema
  • docs/odrl-profile-v2-draft.md — design draft (nothing implemented) for the next widening: union semantics for duplicate actions + CloudEvent-scoped access (dimo:ReadCloudEvents); v1 evaluators reject every v2 construct by construction

Testing

21 table-driven cases in odrl_test.go against ValidateAccessViaSourceDoc: happy path, subset grants, expired / not-yet-effective / unbounded agreements, assignee/target/signature mismatches, strictness rejections (unknown action, prohibition present, wrong @type, tampered @context, unsupported operator, event-filter refusal), scoped-grant decisions (verbatim constraint flow, mixed scoped/unscoped), and per-permission rejections (dateTime on a permission, unknown operator, non-timestamp operand, refinements). Wire-format tests pin the claim split (scoped_permissions present, flat permissions/privilege_ids exclude scoped names; unconditional tokens byte-identical to before). Unit tests for the window evaluator (bound tightening, inclusivity, fail-closed on unknown vocabulary). Full build, vet, and test suite pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DNqJMcyn2p3oKosj3KFdQR

elffjs and others added 2 commits July 9, 2026 17:02
Alongside legacy SACDData documents, the exchange now accepts CloudEvents
of type dimo.sacd.odrl whose data is a W3C ODRL 2.2 Agreement restricted
to DIMO profile v1 (https://ns.dimo.co/odrl/v1). Dispatch is on the
CloudEvent envelope type; legacy documents are untouched.

Profile v1 covers exactly the capabilities of legacy permission grants:
assigner (grantor), assignee (grantee), target (asset DID), a validity
period as conjunctive dateTime constraints, and named permissions using
the existing privilege:* vocabulary. The minted claims are identical to
those from an equivalent legacy document, so downstream consumers (dq)
need no changes. CloudEvent-scoped access is not in profile v1 and such
requests are refused when backed by an ODRL grant.

The evaluator is fail-closed: unknown fields (prohibitions, duties,
refinements), unknown actions, unsupported operators, and any @context
other than the fixed canonical forms reject the document in full. No
JSON-LD processing is ever performed, so a hostile @context cannot remap
term meanings under the grantor's signature.

See docs/odrl-profile-v1.md and docs/odrl-profile-v1.schema.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNqJMcyn2p3oKosj3KFdQR
A permission in a profile v1 agreement may now carry constraints on the
profile-defined left operand dimo:recordedAt, bounding the recording
timestamps of the data the permission may read:

  { "action": "privilege:GetLocationHistory",
    "constraint": [
      { "leftOperand": "dimo:recordedAt", "operator": "gteq",
        "rightOperand": "2026-04-01T00:00:00Z" } ] }

This is distinct from the policy-level dateTime constraints, which bound
when the grant may be exercised and are consumed at exchange time. The
two vocabularies never mix positions: dateTime is rejected on a
permission, dimo:recordedAt at the policy level.

The constraint atom {leftOperand, operator, rightOperand} is now a
shared type in pkg/tokenclaims, used verbatim in both grant documents
and minted tokens: the exchange verifies and consumes the policy
envelope (parties, target, validity, signature) and forwards the
surviving data-level atoms untouched — selection and resolution, never
translation. pkg/tokenclaims also carries the canonical fail-closed
evaluator (RecordedAtWindow, AllowsInterval, AllowsAt) for consumers;
a constraint a consumer cannot interpret denies access.

The claim encoding is fail-closed by construction: a permission granted
under constraints is minted ONLY into the new scoped_permissions claim,
never the flat permissions array or the deprecated privilege_ids. A
consumer reading only the flat claims sees no permission at all, so it
cannot honor a windowed grant while ignoring its window. The gRPC
AccessCheck, whose response cannot express constraints, reports
has_access=false for scoped grants for the same reason.

Enforcement lives in the data services; dq grows it on its
scoped-permissions branch (ranged queries reject ranges outside the
window; latest-value queries are evaluated within it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@elffjs elffjs changed the title Accept new-style SACD grants: ODRL Agreements under DIMO profile v1 Accept ODRL SACD grants under DIMO profile v1, with per-permission data windows Jul 17, 2026
elffjs and others added 2 commits July 17, 2026 11:17
Under the ODRL model, two permission entries naming the same action are
independent grants — a union, e.g. two disjoint data windows. Profile v1
defines no union semantics, and the evaluator's action-keyed grant map
silently honored only the last entry (while dq's claim reader would take
the first): exactly the partial honoring the profile promises never to
do. Reject the document instead, keeping the union shape unambiguous for
a future profile version that defines it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design doc only — nothing implemented; v1 evaluators reject every v2
construct by construction (profile IRI, duplicate actions, unknown
action/operands/operator).

The two additions ship together because the second forces the first: a
grant covering several event tuples needs several permission entries of
the same action, which v1 deliberately rejects. v2 defines the ODRL
reading — same-name entries are alternatives, some-entry-covers
authorizes — which also gives data windows disjoint unions ("the last
two weekends"). dimo:ReadCloudEvents reaches parity with legacy SACD
cloudevent agreements via categorical operands (eventType/Source/ID/Tag,
eq + isAnyOf) minus the magic "*" wildcards, and composes with
dimo:recordedAt in a single entry — something the legacy format cannot
express. Consumer skew under both additions is deny-biased, so v2 can
ship exchange-first.

Co-Authored-By: Claude Fable 5 <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