Add RLS and SELECT privilege enforcement to global graph - #2561
Open
jrgemignani wants to merge 1 commit into
Open
Add RLS and SELECT privilege enforcement to global graph#2561jrgemignani wants to merge 1 commit into
jrgemignani wants to merge 1 commit into
Conversation
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>
jrgemignani
requested review from
MuhammadTahaNaveed,
gregfelice and
muhammadshoaib
and
a lite review from Copilot
September 4, 2026 00:06
There was a problem hiding this comment.
🟡 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, defaulton) 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.
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.
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.