feat(safegres): relation-level API reach via exposure adapters (L6) - #1602
Merged
Conversation
Adapters gain an optional reach(), narrowing an api/schema plane from its schemas to the relations the generated API can address. The postgraphile adapter implements it over @behavior/@forwardBehavior/@backwardBehavior, as graph reachability across foreign keys rather than a per-table test. Role planes are never narrowed: a grant is reachable whatever GraphQL exposes. L6 composes the two halves - an API role holding privileges on a relation its API cannot name - vetoed by any policy predicate that references the relation, since such a grant is load-bearing.
Contributor
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
graphileAdapter answers which schemas a graphile-starter DB serves; postgraphileAdapter answers what the served schemas expose. The former delegates reach to the latter rather than duplicating the behavior read.
ResolvedPlane gained anonRoles; the reach fixtures declare it. The configuration fingerprint already canonicalizes the whole exposure block, so exposure.reach is covered without a change there.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces #1596. That PR read PostGraphile
@behaviortags in the core audit and turned them into one X1 perf hint. Planes landed the day after and made both choices wrong: stack-specific knowledge belongs behind an adapter, and reach is a security question — a plane resolves at schema granularity, and "every relation in an exposed schema is exposed" over-counts a generated API.So
ExposureAdaptergrows one optional method, and everything else falls out of it:postgraphileAdapterimplements it. It is the one built-in that contributes no planes — it has no idea which schemas an API serves — which keeps it orthogonal to the stack adapters from #1601:graphileandconstructiveanswer which schemas are served and delegatereach()here, because what the served schemas expose is a different question with the same answer on any Graphile stack.resolveReachruns every adapter that can answer and intersects: a relation is subtracted only if every adapter that ran agrees, since a wrong subtraction silently deletes findings from the score.Reach is graph traversal, not a per-table test. The core mistake available here is reading
@behavior -select …on a table as "unreachable" — it means no root entry, and the relation may still be addressable by traversing a relation field from one that has:Two invariants: only explicit denial counts (presets grant most behaviors, so absence of
+listsays nothing — silence is never denial), and a hidden reverse relation is one path, not all of them — reported separately ashiddenBackwardRelations, never as an unreachable table.Directional tags are now distinguished, which #1596 did not do and which bit us for real in constructive-db#2679:
@behaviorapplies to both directions and deleted the forward field too.parseBehaviorTagsreturns{ both, forward, backward }, anddirectionalBehavior(tags, dir)resolvestags[dir] ?? tags.both.Role planes are never narrowed. GraphQL not exposing a table says nothing about a role holding a direct connection —
resolvePlaneReachshort-circuits toroleReachbefore reach is consulted. That separation is the whole reason this is safe to apply at all.L6 — unaddressable grant
The composition of the two halves: the lattice knows what a role holds, reach knows what the API can name, and the difference is grant surface no request can use. Gated by a veto that matters:
An RLS policy can subquery a table under the querying role — exactly the SPRT case in constructive-db — so a grant invisible to the API can still be load-bearing, and recommending its revocation would break authorization at runtime, silently.
policyReferencedRelationsis deliberately over-eager: a spurious match costs one unreported finding, a miss costs a broken database.Elsewhere
report.exposure.unaddressable(pretty + markdown), never silently dropped;exposure.reach: falsedisables.behavior-hiddenis now a consequence: adeclaredpath signal fed from the samehiddenBackwardRelations, reported and never acted on. One behavior parser, one reach computation, both axes agreeing about what the API contains.Tested against a fixture with four relations — exposed, root-denied-but-reverse-reachable, fully denied, and fully-denied-but-policy-referenced — asserting the middle one survives and the last one produces no L6. 19 new tests; suite 262 passing.
Link to Devin session: https://app.devin.ai/sessions/ec06ef6eabae4872ae5ec3926f037c85
Requested by: @pyramation