Draft: Enforce fine-grained token boundaries on Orbit graph queries
What does this MR do and why?
A fine-grained personal access token scoped to a group or project could read any Orbit data its owner can see: read_knowledge_graph resolves to the user boundary, and the GKG JWT is built from the user's memberships alone. This MR enforces the token's own boundary, resolving option (b) of gitlab-org/orbit/knowledge-graph#992 (closed).
Primary guarantee: the JWT's traversal paths are narrowed to the intersection of the user's Reporter+ paths and the token's authorized namespace prefixes, so out-of-boundary rows are never produced. This is fail-closed and also bounds aggregation counts. A bounded token drops the admin bypass, and graph_status rejects out-of-boundary targets.
Backstop: the internal redaction callback authenticates with the caller's own token (Workhorse already forwards auth headers), computes the same prefixes, and drops out-of-boundary group and project rows per object. No new wire format or header is involved.
Classic PATs, OAuth tokens, and session requests are unchanged and take zero additional queries (measured); a scoped token adds one namespace batch load per request.
Verification (GDK e2e, 2026-07-27)
Two groups A/B with distinct indexed symbols, non-admin owner of both:
| Token | Symbol in A | Symbol in B | graph_status B | Project count (agg) |
|---|---|---|---|---|
| classic api | visible | visible | 200 | 2 |
| granular, user boundary | visible | visible | 200 | 2 |
| granular, group A boundary | visible | hidden (0 rows) | 404 | 1 |
| granular, project A boundary | visible | hidden (0 rows) | 404 | 1 |
granular without read_knowledge_graph |
403 | 403 | 403 | 403 |
Same narrowing verified on the agent-commands path. A request spec decodes the real emitted JWT for a group-scoped token and asserts it contains only the scoped prefix.
Design notes from adversarial review (5 reviewers: comments, reuse, minimal diff, performance, test matrix)
- The gRPC metadata surfaces (schema, DSL, templates, status, tools) mint user-scoped JWTs without the token. This is safe: no namespace row data flows there (GKG rejects non-immediate agent commands outside the Rails interceptor),
graph_statusis boundary-checked in Rails before the call, and named-query listing binds only the user id. - The route gate is an any-of entry gate by design; it cannot deny a scoped token whose boundary comes from its own scope. Enforcement lives in the JWT narrowing and the redaction backstop.
boundary_type: :userstays on all routes alongside theboundary:lambda because the permissions route validator and generated docs read only the static key; the lambda wins at runtime.- No framework token-to-namespace-set enumerator exists (verified against master and merged authz MRs), so
token_authorized_prefixesis genuinely new; it reusesNamespace#traversal_path,Namespaces::Traversal::TrieNodecompaction, and one batched namespace load. - Prefix truncation past the 500-path cap logs and increments the existing threshold metric; truncation only narrows further (fail-closed).
References
- Resolves gitlab-org/orbit/knowledge-graph#992 (closed)
- Origin thread: #572694 (comment 3537383469)
- Auth-header forwarding to the redaction callback: !244349 (merged)