Skip to content

Add RLS and SELECT privilege enforcement to global graph - #2561

Open
jrgemignani wants to merge 1 commit into
apache:masterfrom
jrgemignani:Add-RLS-and-SELECT-privilege-enforcement-to-global-graph
Open

Add RLS and SELECT privilege enforcement to global graph#2561
jrgemignani wants to merge 1 commit into
apache:masterfrom
jrgemignani:Add-RLS-and-SELECT-privilege-enforcement-to-global-graph

Conversation

@jrgemignani

Copy link
Copy Markdown
Contributor

The global graph cache that backs variable-length edge (VLE) and shortest-path traversal is populated with a direct heap scan (table_beginscan + heap_getnext). That scan does not go through the planner or executor, so the row-level security policies and table privileges that MATCH receives automatically are not applied to traversal: it reads every vertex and edge regardless of the current role's policies or grants.

Add age.enforce_rls_in_traversal (bool, PGC_SUSET, default on). When on:

  • SELECT privilege is checked on every label table before it is read (pg_class_aclcheck, with a pg_attribute_aclcheck fallback so column-level grants are honored the same way the executor honors them).

  • When RLS is active on a label for the current role, that label is loaded through SPI (SELECT ... FROM ONLY ) with a batched read-only cursor so the planner applies the policies. Otherwise the direct-scan path is used, now behind the privilege check. Properties are still fetched lazily by TID: only policy-visible rows are loaded, so the later heap_fetch reads only authorized tuples, which covers shortest_path as well.

When off, the previous direct-scan behavior is restored, leaving operators a single escape hatch.

To match MATCH's inner-join behavior, the edge loaders drop any edge whose start or end vertex was filtered out by a vertex policy rather than leaving a dangling edge in the cache.

Cache validity:

  • A cache that applied RLS is not reused across statements (loaded_with_rls), because policies can depend on session state such as current_setting() that is not covered by the version, snapshot, role, and GUC cache keys. Graphs that do not use RLS are unaffected.

  • is_ggctx_invalid checks the role and GUC keys before the graph version-counter fast path, so a role or GUC change forces a rebuild even in DSM/SHMEM mode.

  • The version counter tracks only graph data changes, so an enforced non-RLS cache could otherwise survive a GRANT/REVOKE or policy/RLS DDL on a label table and serve stale permissions. A relcache callback keyed by label-table OID (table/column privileges, policies, RLS flags), plus pg_authid and pg_auth_members syscache callbacks (BYPASSRLS, role membership), now mark such caches stale so they are rebuilt and re-authorized.

Re-entrancy and cleanup:

  • The context is built detached and attached only after a successful load, so a policy expression that re-enters traversal cannot observe a half-built cache, and a failed load frees the partial context instead of leaving it in TopMemoryContext.

  • On attach, an existing context for the same graph is reused only if it is still valid for the current role and GUC, so a re-entrant traversal reached through a SECURITY DEFINER policy cannot be handed a context loaded as another role. If discarding an incompatible context reports a missing entry, that error is raised after first freeing the freshly built context.

  • Each loader frees the transient label-name list from get_ag_labels_names() on both the normal and error paths; it is allocated in TopMemoryContext and would otherwise accumulate on every rebuild.

Adds regression test rls_vle covering policy and privilege enforcement, the opt-out GUC, per-role cache invalidation, a session current_setting() policy, and privilege-metadata invalidation.

The global graph cache that backs variable-length edge (VLE) and shortest-path
traversal is populated with a direct heap scan (table_beginscan + heap_getnext).
That scan does not go through the planner or executor, so the row-level security
policies and table privileges that MATCH receives automatically are not applied
to traversal: it reads every vertex and edge regardless of the current role's
policies or grants.

Add age.enforce_rls_in_traversal (bool, PGC_SUSET, default on). When on:

* SELECT privilege is checked on every label table before it is read
  (pg_class_aclcheck, with a pg_attribute_aclcheck fallback so column-level
  grants are honored the same way the executor honors them).

* When RLS is active on a label for the current role, that label is loaded
  through SPI (SELECT ... FROM ONLY <label>) with a batched read-only cursor so
  the planner applies the policies. Otherwise the direct-scan path is used, now
  behind the privilege check. Properties are still fetched lazily by TID: only
  policy-visible rows are loaded, so the later heap_fetch reads only authorized
  tuples, which covers shortest_path as well.

When off, the previous direct-scan behavior is restored, leaving operators a
single escape hatch.

To match MATCH's inner-join behavior, the edge loaders drop any edge whose start
or end vertex was filtered out by a vertex policy rather than leaving a dangling
edge in the cache.

Cache validity:

* A cache that applied RLS is not reused across statements (loaded_with_rls),
  because policies can depend on session state such as current_setting() that is
  not covered by the version, snapshot, role, and GUC cache keys. Graphs that do
  not use RLS are unaffected.

* is_ggctx_invalid checks the role and GUC keys before the graph version-counter
  fast path, so a role or GUC change forces a rebuild even in DSM/SHMEM mode.

* The version counter tracks only graph data changes, so an enforced non-RLS
  cache could otherwise survive a GRANT/REVOKE or policy/RLS DDL on a label table
  and serve stale permissions. A relcache callback keyed by label-table OID
  (table/column privileges, policies, RLS flags), plus pg_authid and
  pg_auth_members syscache callbacks (BYPASSRLS, role membership), now mark such
  caches stale so they are rebuilt and re-authorized.

Re-entrancy and cleanup:

* The context is built detached and attached only after a successful load, so a
  policy expression that re-enters traversal cannot observe a half-built cache,
  and a failed load frees the partial context instead of leaving it in
  TopMemoryContext.

* On attach, an existing context for the same graph is reused only if it is still
  valid for the current role and GUC, so a re-entrant traversal reached through a
  SECURITY DEFINER policy cannot be handed a context loaded as another role. If
  discarding an incompatible context reports a missing entry, that error is
  raised after first freeing the freshly built context.

* Each loader frees the transient label-name list from get_ag_labels_names() on
  both the normal and error paths; it is allocated in TopMemoryContext and would
  otherwise accumulate on every rebuild.

Adds regression test rls_vle covering policy and privilege enforcement, the
opt-out GUC, per-role cache invalidation, a session current_setting() policy,
and privilege-metadata invalidation.

Co-authored-by: GitHub Copilot (Claude Opus 5) <noreply@githubcopilot.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new SPI-based loaders do not check for SPI_cursor_open() returning NULL, which can lead to NULL portal dereference during traversal cache load.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR hardens AGE’s global graph cache (used by variable-length edge traversal and related algorithms) so it enforces PostgreSQL SELECT privileges and row-level security (RLS) policies, aligning traversal visibility with what MATCH would allow.

Changes:

  • Adds age.enforce_rls_in_traversal (PGC_SUSET, default on) to gate SELECT-ACL checks and RLS-aware loading during traversal cache population.
  • Updates global-graph cache loading to (a) explicitly enforce SELECT privileges for direct heap scans and (b) route RLS-enabled label tables through SPI cursor-based reads so the planner applies policies; edges referencing RLS-filtered vertices are dropped to avoid dangling edges.
  • Adds cache invalidation improvements (role/GUC keying, “never reuse across statements” when RLS was applied, and relcache/syscache callbacks for ACL/RLS/role-auth changes) plus a regression test covering the behavior.
File summaries
File Description
src/include/utils/ag_guc.h Declares the new traversal enforcement GUC.
src/backend/utils/ag_guc.c Defines age.enforce_rls_in_traversal (SUSET, default on).
src/backend/utils/adt/age_global_graph.c Implements privilege/RLS enforcement, RLS-aware SPI loaders, cache keying & invalidation callbacks, and re-entrancy-safe attach behavior.
regress/sql/rls_vle.sql Adds regression coverage for RLS + ACL enforcement, opt-out, per-role invalidation, and session-dependent policies.
regress/expected/rls_vle.out Expected output for the new regression test.
Makefile Adds rls_vle to the regression test suite.
Review details

Suppressed comments (1)

src/backend/utils/adt/age_global_graph.c:931

  • SPI_cursor_open() can return NULL (and set SPI_result) on failure; the subsequent SPI_cursor_fetch()/SPI_cursor_close() would then dereference a NULL portal and likely crash. Add an explicit NULL check and raise an ERROR with SPI_result_code_string(SPI_result).
    /* read-only cursor: reuses the active snapshot; RLS applied by planner */
    portal = SPI_cursor_open(NULL, plan, NULL, NULL, true);

  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/backend/utils/adt/age_global_graph.c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants