Skip to content

feat(safegres): relation-level API reach via exposure adapters (L6) - #1602

Merged
pyramation merged 3 commits into
mainfrom
feat/safegres-api-reach
Aug 2, 2026
Merged

feat(safegres): relation-level API reach via exposure adapters (L6)#1602
pyramation merged 3 commits into
mainfrom
feat/safegres-api-reach

Conversation

@pyramation

@pyramation pyramation commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces #1596. That PR read PostGraphile @behavior tags 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 ExposureAdapter grows one optional method, and everything else falls out of it:

interface ExposureAdapter {
  name; detect(exec); resolve(exec);
  reach?(exec, ctx: ReachContext): Promise<ApiReach>;  // new
}

postgraphileAdapter implements 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: graphile and constructive answer which schemas are served and delegate reach() here, because what the served schemas expose is a different question with the same answer on any Graphile stack. resolveReach runs 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:

roots      = relations not denying ALL of select/insert/update/delete
edges      = FKs, minus directions an explicit denial removes
             backward: referenced → referencing, unless -list -connection -single
             forward:  referencing → referenced, unless -single
unreachable = relations no traversal from any root visits

Two invariants: only explicit denial counts (presets grant most behaviors, so absence of +list says nothing — silence is never denial), and a hidden reverse relation is one path, not all of them — reported separately as hiddenBackwardRelations, 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: @behavior applies to both directions and deleted the forward field too. parseBehaviorTags returns { both, forward, backward }, and directionalBehavior(tags, dir) resolves tags[dir] ?? tags.both.

Role planes are never narrowed. GraphQL not exposing a table says nothing about a role holding a direct connection — resolvePlaneReach short-circuits to roleReach before 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:

if (!unaddressable.has(key) || policyReferenced.has(key)) return [];

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. policyReferencedRelations is deliberately over-eager: a spurious match costs one unreported finding, a miss costs a broken database.

Elsewhere

  • Subtractions are listed in report.exposure.unaddressable (pretty + markdown), never silently dropped; exposure.reach: false disables.
  • X1's behavior-hidden is now a consequence: a declared path signal fed from the same hiddenBackwardRelations, 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

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.
@pyramation pyramation self-assigned this Aug 2, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

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.
@pyramation
pyramation merged commit d8560c5 into main Aug 2, 2026
16 checks passed
@pyramation
pyramation deleted the feat/safegres-api-reach branch August 2, 2026 07:04
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