# 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');
```