Skip to content

Enforce scoped permissions (ODRL data windows) from dauth tokens - #25

Open
elffjs wants to merge 2 commits into
mainfrom
scoped-permissions
Open

Enforce scoped permissions (ODRL data windows) from dauth tokens#25
elffjs wants to merge 2 commits into
mainfrom
scoped-permissions

Conversation

@elffjs

@elffjs elffjs commented Jul 17, 2026

Copy link
Copy Markdown
Member

What

Companion to DIMO-Network/dauth#17. dauth's exchange can now mint permissions granted only for data recorded within a time window (DIMO ODRL profile v1, dimo:recordedAt). Those arrive in a new scoped_permissions claim:

"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" } ] } ]

The encoding is fail-closed by construction: windowed permissions are deliberately absent from the flat permissions array, so dq as deployed today already refuses windowed tokens everywhere with zero changes. This PR opens each surface deliberately, with the window enforced. The new internal/scope package is the one place that reads scoped_permissions — always alongside its constraints — and a constraint dq cannot interpret grants nothing.

Enforcement model

Ranged queries reject; point queries window-evaluate. The dividing line is whether the response could misrepresent what was computed:

  • Ranged queriessignals, events, segments, dailyActivity, and cloud-event reads (after/before) — are rejected when the requested range exceeds the window, with the window named in the error. Silent clamping would return an aggregate computed over a narrower range than requested, labeled as the full range — a wrong answer presented as right. An absent range means "all time" and only an unbounded grant passes, so integrators must state ranges inside their window.
  • Latest-value queriessignalsLatest, signalsSnapshot — are evaluated within the window: a value recorded outside it is withheld, which is indistinguishable from the vehicle not having transmitted then, and every returned value carries its timestamp, keeping results self-describing. The derived approximate location is gated separately from the raw coordinates row (either qualifying permission may allow a timestamp the other does not).
  • Possession checks (schema directives, per-signal privilege filter) count scoped permissions as held; the window is enforced at the layers above.

Surfaces that stay closed for scoped grants (all-time facts that can't be windowed post hoc; follow-up work to teach them window-bounded queries):

  • availableSignals / dataSummary exclude signals behind scoped permissions — listing a name or a count/first-seen asserts all-time facts. The summary's top-level first/last-seen fold is recomputed from surviving entries so it can't leak filtered timestamps.
  • lastSeen (computed across every signal) is suppressed for scoped tokens.
  • The gRPC fetch surface still reads only the flat claim — scoped grants don't open it.

Files

  • internal/scope/ — possession + window evaluation over the token (Holds, Unscoped, AllowsRange, AllowsAt, Describe), fail-closed on unknown constraint vocabulary
  • internal/auth/directives.go — directives accept scoped possession
  • internal/graph/privilege_filter.go — token-aware per-signal checks: possession, unscoped-only, range, per-value visibility
  • internal/graph/arguments.go — per-signal range rejection for signals; latest-args window hooks + lastSeen suppression
  • internal/graph/base.resolvers.go, events.resolvers.go, segments.resolvers.go — surface wiring
  • internal/graph/auth_helpers.go — cloud-event raw-data path: unscoped fast path, windowed path requires in-window after/before
  • internal/repositories/signals.go + internal/graph/model/signalArgs.go — latest-row window filter; approximate location derived from the raw row so it can be served when the raw coordinates are withheld
  • internal/auth/grpc.go — comment documenting the (already fail-closed) scoped behavior

⚠️ Before merge

go.mod temporarily carries replace github.com/DIMO-Network/dauth => ../dauth so CI here will fail until DIMO-Network/dauth#17 merges and a dauth version is tagged; then the replace is dropped and the requirement bumped.

Testing

  • internal/scope: possession/window semantics, exclusive vs inclusive bounds, fail-closed on uninterpretable constraints (a geofence-constrained grant is "held" but allows no data).
  • internal/graph/scoped_windows_test.go: per-signal range checks (windowed location vs unconditional non-location, derived approximate location), per-value visibility, unscoped-only filtering for the all-time surfaces, and the cloud-event path (in-window bounds pass; exceeding or absent bounds rejected; unconditional raw-data unaffected).
  • Full build, vet, and test suite pass (with the local replace).

🤖 Generated with Claude Code

elffjs and others added 2 commits July 17, 2026 08:52
dauth's exchange can now mint permissions granted only for data recorded
within a time window (DIMO ODRL profile v1, dimo:recordedAt). Such
permissions arrive in a new scoped_permissions claim, deliberately
absent from the flat permissions array, so before this change dq
refused windowed tokens everywhere — fail-closed by encoding. This
change opens each surface deliberately, with the window enforced:

- Possession checks (schema directives, per-signal privilege filter)
  count scoped permissions as held; the new internal/scope package is
  the one place that reads them, always alongside their constraints.
  A constraint dq cannot interpret grants nothing.

- Ranged queries — signals, events, segments, dailyActivity, and
  cloud-event reads (before/after) — REJECT requests whose range
  exceeds the window, naming the window in the error. Rejection rather
  than silent clamping: an aggregate computed over a narrower range
  than requested would be mislabeled as covering the full range. An
  absent range means all time and only an unbounded grant passes.

- Latest-value queries (signalsLatest, signalsSnapshot) are evaluated
  WITHIN the window: a value recorded outside it is withheld, which is
  indistinguishable from the vehicle not having transmitted then, and
  the returned timestamps keep the result self-describing. The derived
  approximate location is gated separately from the raw coordinates
  row, since either qualifying permission may allow a timestamp the
  other does not.

- All-time surfaces that cannot yet window their answers stay closed
  for scoped grants: availableSignals and dataSummary exclude signals
  behind scoped permissions (their counts and first/last-seen are
  unwindowable post hoc; the summary's top-level fold is recomputed
  from what survives), lastSeen is suppressed, and the gRPC fetch
  surface still reads only the flat claim. Teaching these surfaces
  window-bounded queries is follow-up work.

NOTE: go.mod temporarily replaces github.com/DIMO-Network/dauth with a
local path; swap for a tagged dauth release (DIMO-Network/dauth#17)
before merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bug: the signals range check rejected the WHOLE query when a scoped
token requested a signal whose permission it does not hold at all —
where the design (and main's behavior) leaves possession to the
per-field privilege directives. A scoped token mixing one windowed
signal with one unheld signal got an opaque window rejection instead of
the directive's per-field missing-privilege error. The range check now
speaks only for permissions the token actually holds, mirroring the
latest path's signalValueVisible split.

Also adds tests/scoped_windows_graphql_test.go: end-to-end tests over
the REAL executable schema with the REAL auth directives (the existing
harnesses stub auth) and a real DuckLake-backed repository, with claims
JSON-round-tripped exactly as they ride in a dauth-minted JWT. Covers:
in-window ranged queries served, out-of-window ranges rejected naming
the window, unconditional signals unaffected, latest served in-window /
withheld out-of-window, lastSeen suppression, approximate location
served from a raw row the location window withholds, availableSignals/
dataSummary exclusion + count recomputation + event-summary drop,
events range rejection, and the unheld-signal-is-a-field-error fix.

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