# Administration And Security Administrative functions mutate extension catalogs, build state, persisted artifacts, sync state, or run global graph algorithms. The code checks graph admin privileges before these operations. ## Graph Admin Check A caller is considered a graph admin when either: | Condition | Source | |---|---| | Current role is a superuser | `pg_roles.rolsuper` | | Current role has `CREATE` privilege on schema `graph` | `has_schema_privilege(current_user, 'graph', 'CREATE')` | Grant a non-superuser admin role: ```sql GRANT USAGE, CREATE ON SCHEMA graph TO graph_admin; ``` ## Security-Definer Execution pgGraph functions that run with definer privileges pin their PostgreSQL `search_path` to `pg_catalog, pg_temp`. Listing `pg_temp` after `pg_catalog` prevents temporary objects from taking implicit precedence over system catalogs. This prevents objects in a caller-controlled schema from changing how those privileged functions resolve built-in names or catalog relations. The same policy applies to the row and truncate trigger functions created by `graph.enable_sync()` for registered source tables. Registered relation identities are stored as PostgreSQL OIDs, so renaming a registered table does not change its mapping and a drop/recreate does not silently retarget it. Applications should still schema-qualify their own database objects and grant only the graph functions each role requires. ## Admin-Protected Functions Admin protection applies to catalog mutation, build/vacuum/maintenance, sync apply, reset, and global analytics. | Function family | Examples | |---|---| | Registration | `add_table`, `add_edge`, `add_filter_column`, `rename_edge`, `alter_edge`, `remove_table`, `remove_edge` | | Build lifecycle | `build`, `vacuum`, `maintenance`, `reset` | | Sync | `enable_sync`, `apply_sync`, `add_sync_policy`, `alter_sync_policy`, `drop_sync_policy`, `run_sync_policy`, `run_job`, `run_due_jobs`, `run_due_jobs_async`, `alter_job`, `remove_job`, `projection_compact`, `projection_gc`, `projection_repair` | | Global analytics | `connected_components`, `component_stats`, component pagination helpers | ## Graph Privileges Named graphs can grant graph-level privileges to PostgreSQL roles: ```sql SELECT * FROM graph.grant_graph('customer_360', 'app_reader', 'read', namespace := 'analytics'); SELECT * FROM graph.grant_graph('customer_360', 'etl_worker', 'build', namespace := 'analytics'); SELECT * FROM graph.graph_privileges('customer_360', namespace := 'analytics'); ``` | Privilege | Effect | |---|---| | `read` | Allows selecting/querying the graph when source-table privileges also allow access | | `write` | Reserved for mapped graph writes; source-table write privileges remain required | | `build` | Allows build, vacuum, and maintenance for the graph | | `admin` | Allows grant/revoke and graph lifecycle administration | Graph owners, graph schema admins, and graph `admin` grantees can grant and revoke graph privileges. Graph owners and graph schema admins retain all graph privileges. ## Graph Quotas Graph schema admins can configure quota policies: ```sql SELECT * FROM graph.set_graph_quota( 'owner', 'max_named_graphs', 25, scope_key := 'app_owner', enforcement := 'hard' ); SELECT * FROM graph.graph_quota_usage(); ``` Quota scopes are `cluster`, `tenant`, `owner`, `namespace`, and `graph`. Enforcement can be `hard` or `warn`. Hard `max_named_graphs` quotas check cluster and owner limits before creating a graph metadata row. Hard `max_loaded_graphs_per_backend` quotas check cluster and owner limits before loading a persisted graph artifact into the current backend. Hard `max_graph_jobs` quotas count generic jobs, build jobs, and maintenance jobs before creating a sync policy job. Hard `max_artifact_storage_bytes` quotas check active projection artifact bytes plus the requested ingest or compaction write budget before publishing new derived files. Warning quotas are visible through `graph.graph_quota_usage()` but do not block the operation. ## Reader Functions Application roles typically need execute privileges on search/traversal/path functions and `SELECT` privilege on the source tables they query. ```sql GRANT USAGE ON SCHEMA graph TO app_reader; GRANT SELECT ON public.users TO app_reader; GRANT SELECT ON public.orders TO app_reader; ``` Then grant only the functions your application uses. PostgreSQL function grants must match argument types; inspect installed signatures with: ```sql SELECT p.oid::regprocedure FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'graph' ORDER BY p.proname, p.oid::regprocedure::text; ``` ## Source Table ACL Checks Query functions check source-table `SELECT` privileges before accessing graph data for the relevant table coordinates. Missing privilege raises SQLSTATE `42501` with diagnostic `PG002`. Mapped GQL writes also preflight the privileges they need on source tables: `MERGE` requires `SELECT` and `INSERT`, and it requires `UPDATE` only when the query includes `ON MATCH SET`. Graph `read` is necessary but not sufficient for reading source data. A role with graph `read` but no source-table `SELECT` still receives SQLSTATE `42501` with diagnostic `PG002` when a query hydrates or otherwise reads source rows. A role with source-table `SELECT` but no graph `read` cannot select or query that graph. ## Tenant Scope When a selected graph has a `tenant` value, pgGraph uses it as the default tenant scope for traversal, search, GQL, and Cypher calls. An explicit tenant argument or a session tenant from `graph.tenant_setting` must match the selected graph tenant; mismatches return SQLSTATE `22023` with diagnostic `PG005`. Resolution order is: 1. explicit tenant argument; 2. session tenant from `graph.tenant_setting`; 3. selected graph tenant; 4. no tenant, unless `graph.enforce_tenant_scope = on` and registered tables have `tenant_column`. For a graph whose tables are registered with `tenant_column` rather than pinned to a single graph-level tenant, an explicit tenant argument is a caller-supplied value, not one verified against the calling role's own identity. While `graph.enforce_tenant_scope = on` (the default), pgGraph refuses an explicit tenant argument for such a graph with SQLSTATE `22023` instead of accepting it: tenant scope must come from the session tenant (`graph.tenant_setting`) instead of a per-query argument. This closes the most direct form of self-service tenant selection, but pgGraph still cannot verify that a session's tenant setting reflects who is actually connected; that trust boundary belongs to whatever sets the session GUC (a connection pooler, a `SECURITY DEFINER` login trigger, or another trusted middle tier) before the caller's own queries run. If different tenants must not be able to read each other's data regardless of session state, also enforce it with PostgreSQL row-level security policies on the underlying tables, or register separate graphs per tenant with separate graph grants. Setting `graph.enforce_tenant_scope = off` removes this check entirely and restores the caller-suppliable-argument behavior; do not disable it for a tenant-sensitive deployment. The graph artifact contains source primary keys and topology. ACL checks are part of query execution so a role cannot use graph queries to bypass source table `SELECT` permissions. ## Row-Level Security And Topology Reads `graph.search()` re-queries source-table properties through SQL as the calling role, so row-level security policies on the underlying tables apply exactly as they would to a direct `SELECT`. `graph.traverse()`, `graph.shortest_path()`, `graph.weighted_shortest_path()`, and the connected-component functions read the built graph artifact instead of issuing a fresh SQL scan. That artifact is constructed once by whoever ran `graph.build()` — typically an owning or elevated role — from every row the builder could see, and it is not re-filtered per calling role afterward. These functions check table-level `SELECT` privilege (`PG002`) before returning results, but they do not evaluate row-level security policies against the calling role. By default (`graph.allow_rls_tables = off`), `graph.build()` refuses to register any table that has row-level security enabled (`relrowsecurity`/`relforcerowsecurity`) with SQLSTATE `55000` and diagnostic `PG021`, for both node tables and edge endpoint tables. This makes the exposure above a deliberate, acknowledged operator decision instead of a silent one: building over an RLS-enabled table requires explicitly setting `graph.allow_rls_tables = on` first. Setting `graph.allow_rls_tables = on` does not add row-level filtering to topology reads — it only lets `graph.build()` proceed over an RLS-enabled table. Once acknowledged, a role with table-level `SELECT` but no row-level access to a specific row can still receive that row's `node_id`/`node_table` coordinate, its position in a traversal or path, and whether it is reachable from another node, through `graph.traverse()`, `graph.shortest_path()`, `graph.weighted_shortest_path()`, and the component functions. Requesting `hydrate := true` re-checks the source row through SQL as the caller and omits or nulls the hydrated payload when row-level security denies it, but the coordinate itself is still returned by these functions. If your access-control model requires row-level security to extend to graph topology, keep `graph.allow_rls_tables = off` and do not register RLS-enabled tables. If you do acknowledge and build over one, do not grant `EXECUTE` on `graph.traverse()`, `graph.shortest_path()`, `graph.weighted_shortest_path()`, or the component functions to roles that should not see the full topology the graph was built from. Treat a built graph as a single artifact reflecting the builder's row visibility, not a per-caller-filtered view, and restrict which roles can query it accordingly. ## Internal Catalog Tables Internal catalog tables are implementation details. Bootstrap SQL revokes direct `PUBLIC` read and write access to sensitive named-graph, grant, quota, job, sync, and projection metadata tables: ```text graph._graphs graph._graph_grants graph._graph_quotas graph._jobs graph._job_runs graph._sync_policies graph._sync_log graph._projection_generations graph._sync_watermarks graph._sync_buffer ``` Application code should not depend on raw internal reads. Use filtered public functions such as `graph.list_graphs()`, `graph.graph_privileges(...)`, `graph.graph_quotas()`, `graph.graph_quota_usage()`, `graph.jobs(...)`, `graph.job_runs(...)`, `graph.sync_health()`, and `graph.projection_status()`. Extension owners and superusers can still inspect internals for maintenance when needed. ## Durable Generation Publication Persisted projections publish as immutable generation manifests. pgGraph writes and fsyncs each candidate manifest, validates every active artifact reference, and then atomically replaces a checksummed current-generation pointer. Sync, compaction, repair, and rebuild publishers compare that pointer with the generation they planned from; a competing publisher returns SQLSTATE `55P03` with diagnostic `PG006` instead of replacing newer state. Readers continue using the immutable generation already installed in their backend while a replacement is prepared. Backend generation heartbeats and `graph.projection_retention_generations` protect active and rollback generations. `graph.projection_gc()` follows the current generation's ancestry, removes unreferenced obsolete artifacts, and bounds old or abandoned manifest files without moving the serving pointer. The default retention of two keeps the current and previous valid generation when no older reader pin requires more. An interrupted or failed publication can leave an unreferenced staging file or generation manifest, but it cannot make that candidate current. The next GC pass removes abandoned manifests that are neither retained nor pinned. If the current pointer or its referenced manifest fails checksum validation, pgGraph fails closed; rebuild or use `graph.projection_repair()` from authoritative PostgreSQL source tables. ## Error Codes The extension emits standard PostgreSQL SQLSTATEs. Its stable diagnostic code appears in `DETAIL` as `pgGraph diagnostic: PGxxx`, allowing applications and operators to distinguish pgGraph conditions that share a standard SQLSTATE. | SQLSTATE | Diagnostic | Error | Common fix | |---|---|---|---| | `53200` | `PG001` | Memory limit exceeded | Raise `graph.memory_limit_mb` or reduce registered graph size | | `42501` | `PG002` | ACL denied | Grant `SELECT` on the relevant source table or graph admin privilege for admin functions | | `55000` | `PG003` | Graph not built | Run `SELECT * FROM graph.build();` | | `54000` | `PG004` | Edge type limit exceeded | Reduce distinct edge labels; max user labels are 254 | | `22023` | `PG005` | Invalid filter | Register the filter column and use supported operators/types | | `55P03` | `PG006` | Build locked | Wait for current build/vacuum/maintenance | | `54000` | `PG007` | Resource limit exceeded | Reduce query/work size or raise the relevant query, maintenance, disk, work, or elapsed limit | | `54000` | `PG008` | Edge buffer full | Run `graph.vacuum()`/`graph.maintenance()` or increase `graph.edge_buffer_size` | | `XX001` | `PG009` | Corrupt graph file | Rebuild persisted artifact | | `P0002` | `PG010` | Node not found | Check table/primary key and rebuild/apply sync if data changed | | `0A000` | `PG011` | Incompatible graph file version | Rebuild artifact with current extension version | | `55000` | `PG012` | Read-only graph state | Inspect `graph.status().read_only_reason`, then rebuild, vacuum, or adjust memory policy | | `42601` | `PG013` | GQL syntax error | Check the query text against the documented GQL subset | | `0A000` | `PG014` | Unsupported GQL feature | Rewrite the query using supported GQL compatibility-matrix features | | `22023` | `PG015` | GQL semantic error | Verify labels, relationship types, aliases, and return bindings | | `22023` | `PG016` | GQL parameter error | Pass a JSON object containing every `$parameter` referenced by the query | | `22000` | `PG017` | GQL execution error | Fix invalid runtime values, reduce result cardinality, or rebuild/apply sync if a graph coordinate can no longer hydrate its source row | | `0A000` | `PG018` | Unsupported graph operation | Use a supported query shape or merge pending overlays before retrying | | `54000` | `PG019` | Overlay limit exceeded | Commit or roll back the transaction, or raise the relevant overlay GUC | | `55000` | `PG020` | Extension disabled | `SET graph.enabled = on` | | `55000` | `PG021` | Row-level security topology boundary | Review the RLS/topology boundary, then `SET graph.allow_rls_tables = on` to acknowledge and build anyway | | `55000` | `PG022` | Sync replay position predates a prune | Run `graph.build()` or `graph.vacuum()` for a fresh consistent base, then resume normal sync | | `XX000` | `PG000` | Internal error | Report with full message and reproduction | ## Disable Query Functions ```sql SET graph.enabled = off; ``` When disabled, query functions fail with SQLSTATE `55000`. Administrative functions such as `status`, `build`, and `reset` are not intended as regular query paths and are not all gated by the kill switch. ## Backup And Restore The bootstrap SQL marks extension-owned operational tables for config dump with `pg_extension_config_dump`. This preserves registration, jobs, and unapplied sync rows across dump/restore. Source tables remain authoritative for graph contents. PostgreSQL relation OIDs are local to a database, so a logical restore into a new database does not automatically rebind restored graph registrations to relations that happen to have the same names. This fail-closed behavior prevents a dropped and recreated relation from silently taking over an existing registration. After a logical restore into a new database, reset the restored derived graph state, reapply the reviewed table, edge, and filter registrations against the restored relations, then rebuild and re-enable synchronization. Re-enabling synchronization replaces any logically restored triggers whose function names refer to relation OIDs from the source database: ```sql SELECT graph.reset(); -- Reapply graph.add_table(...), graph.add_edge(...), and -- graph.add_filter_column(...) calls for the restored source schema. SELECT * FROM graph.build(); SELECT graph.enable_sync(); ``` Keep the registration SQL with your schema migrations so this procedure is repeatable. A physical recovery that preserves the PostgreSQL data directory and relation OIDs may retain registrations, but derived persisted graph files still require validation; rebuild from the source tables whenever those files were not restored or cannot be validated. ## Admin SQL Examples Use the named-graph APIs when you want to work on a graph without changing the session default. **Create, register, and build** ```sql SELECT * FROM graph.create_graph('customer_360', namespace := 'analytics'); SELECT * FROM graph.add_table_to_graph( 'customer_360', 'public.customers'::regclass, 'id', ARRAY['name', 'email'], graph_namespace := 'analytics' ); SELECT * FROM graph.add_edge_to_graph( 'customer_360', 'public.orders'::regclass, 'customer_id', 'public.customers'::regclass, 'id', 'placed_order', graph_namespace := 'analytics' ); SELECT * FROM graph.build_graph('customer_360', force_persist := true, graph_namespace := 'analytics'); ``` **Select, sync, and run jobs** ```sql SELECT * FROM graph.select_graph('customer_360', namespace := 'analytics'); SELECT graph.enable_sync(); SELECT * FROM graph.add_sync_policy('customer_360', schedule_interval_secs := 300, graph_namespace := 'analytics'); SELECT * FROM graph.jobs('customer_360', graph_namespace := 'analytics'); SELECT * FROM graph.run_due_jobs(); ``` **Set residency and quotas** ```sql SELECT * FROM graph.set_graph_residency('customer_360', 'warm', namespace := 'analytics'); SELECT * FROM graph.set_graph_quota('owner', 'max_named_graphs', 25, current_user::text, 'hard'); SELECT * FROM graph.graph_quota_usage(); ``` **Inspect storage, status, and memory** ```sql SELECT artifact_bytes, segment_count, artifact_validation_state FROM graph.projection_status(); SELECT node_count, edge_count, schema_status, sync_status, sync_lag FROM graph.status(); SELECT * FROM graph.memory_profile(concurrent_backends := 4); ``` **Grant access and export the map** ```sql SELECT * FROM graph.grant_graph('customer_360', 'app_reader', 'read', namespace := 'analytics'); SELECT * FROM graph.graph_map('customer_360', graph_namespace := 'analytics'); ``` **Direct node lookup and search** ```sql SELECT * FROM graph.get_node('customer_360', 'customers', 'cust_123', graph_namespace := 'analytics'); SELECT * FROM graph.search('name', 'Ada'); ```