Skip to content

Enforce fine-grained CloudEvent grants#6

Draft
elffjs wants to merge 1 commit into
mainfrom
cloud-event-grants
Draft

Enforce fine-grained CloudEvent grants#6
elffjs wants to merge 1 commit into
mainfrom
cloud-event-grants

Conversation

@elffjs

@elffjs elffjs commented Jun 15, 2026

Copy link
Copy Markdown
Member

Context

dq is a fusion of telemetry-api and fetch-api. telemetry-api could honor a narrow cloud_events JWT grant — authorizing specific event types, from specific sources, optionally pinned to specific IDs, rather than coarse "read all cloud events." fetch-api never had this, and the merged service inherited fetch-api's coarse-only check. The cloud_events grant already arrived in context but was never consulted.

This restores the capability. It's security-sensitive — the default is fail-closed.

Approach

Two independent authorization paths for the CloudEvent queries (cloudEvents, latestCloudEvent, availableCloudEventTypes), OR'd together — a request is allowed if either applies:

  1. Full access via permissionsGetRawData, or the GetLocationHistory + GetNonLocationHistory combo. Reads all of the subject's events (unchanged behavior).
  2. A scoped cloud_events grant — evaluated independently of the permissions enum; the grant is self-authorizing.

Enforcement is up-front and fail-closed (cloudEventRequestAllowed / grantCovers, ported from telemetry-api's validCloudEventRequest): a request is authorized only if its filter falls entirely within a single grant. Unset filter dimensions default to the * wildcard, so a narrow grant requires the caller to scope their query to match it. We validate rather than post-filter results — a request that can't be fully served is rejected, not partially serviced. Subject-DID scoping still applies in both paths.

AdvancedSearchOptions AND's its top-level fields and only OR's within a single field, so the OR-of-ANDs grant model can't be losslessly pushed into one query — validating the request (gate, don't rewrite) is the clean fit.

Scope

  • Single chokepoint requireSubjectOptsByDID covers all three resolvers and the MCP surface (same resolvers via @mcpTool). No resolver changes needed.
  • No JWT/middleware changes — grants already deserialize into DQClaimtokenclaims.Token.CloudEvents.

Out of scope

  • The internal gRPC FetchService remains an unauthenticated, network-isolated, trusted surface by design — grants are not enforced there.
  • Grant tags are not checked (no tags field on CloudEventFilter; telemetry-api ignored them too).
  • Multi-ID grants are usable one ID per query (parity with telemetry-api). Could later inject grant-derived id IN (...) when the caller under-specifies — deferred.

Changes

  • internal/graph/auth_helpers.go — replace requireRawDataToken with hasFullCloudEventAccess + cloudEventRequestAllowed / grantCovers, wired into requireSubjectOptsByDID.
  • internal/graph/cloud_events_auth_test.go (new) — 22 table-driven cases (wildcard / narrow / specific-ID grants, plural types, full-access bypass, grant-only access, subject mismatch).
  • README.md, MERGER_REVIEW.md — document the two-path model and the gRPC out-of-scope decision.

Verification

go build ./..., go vet ./internal/graph/, go test ./internal/graph/ all pass.

🤖 Generated with Claude Code

Restore telemetry-api's narrow `cloud_events` JWT grant support, which
fetch-api never had and which the merged service inherited as coarse-only.

The CloudEvent queries (cloudEvents, latestCloudEvent,
availableCloudEventTypes) now authorize a request via either of two
independent paths, OR'd together:

  1. Full access via the permissions enum (GetRawData, or the
     GetLocationHistory + GetNonLocationHistory combo).
  2. A scoped cloud_events grant, evaluated independently of the enum,
     authorizing specific event types / sources / ids.

Enforcement is up-front and fail-closed (ported from telemetry-api's
validCloudEventRequest): a request is allowed only if its filter falls
entirely within a single grant, with unset filter dimensions defaulting
to the "*" wildcard. We validate rather than post-filter results, so a
request that cannot be fully served is rejected instead of partially
serviced. Subject-DID scoping still applies in both paths.

The single chokepoint (requireSubjectOptsByDID) covers all three
resolvers and the MCP surface. The internal gRPC FetchService remains an
unauthenticated, trusted surface by design and is out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <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