# 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`.
The 1.1 topology APIs build one statement-scoped
visibility scope after freshness handling. For each registered source relation
where PostgreSQL says RLS applies to the outer caller, pgGraph scans
caller-visible source keys and intersects them with projected node and
relationship identities. Hidden seeds and targets behave as nonexistent, and
hidden nodes or relationship rows are rejected before they affect
reachability, path selection, weighted costs, limits, or hydration. This covers
single- and multi-seed BFS/DFS, inbound traversal, search-derived traversal,
`get_neighbors()`, `expand()`, `find_related()`, `neighborhood()`, shortest
paths, weighted shortest paths, and the direct `path()` and `connection()`
wrappers.
`graph.build()` now accepts RLS-enabled node and relationship source tables by
default. `graph.allow_rls_tables` remains registered for 1.x configuration
compatibility but is a deprecated no-op. `graph.rls_mode = 'enforce'` is the
default. A superuser can select `legacy_bypass` as a temporary compatibility
escape hatch; table ACL checks still apply.
The same visibility scope is used by components, aggregation, GQL/Cypher,
derived workflows, path enumeration, and topology-producing mapped-write
matching. PostgreSQL DML then rechecks source-table RLS, ACLs, constraints, and
triggers before a mapped write commits.
An RLS-active projected relationship must carry its durable source-row
identity. A legacy artifact that lacks that identity fails closed with SQLSTATE
`55000`, diagnostic `PG023`, and a rebuild-required hint.
## Query Identity And Telemetry Authorization
In pgGraph 1.1 query entry points execute as the PostgreSQL caller. Source-table
SQL used for hydration and visibility checks therefore sees the application
role and its normal ACL/RLS environment. pgGraph does not switch Rust user IDs.
Selected-graph resolution and private catalog maintenance use small
`SECURITY DEFINER` mediators that capture the outer role, pin `search_path` to
`pg_catalog, pg_temp`, and enforce graph grants before accessing internal
catalogs.
Operational status reports physical projection state; their counts are not
RLS-filtered query results. `graph.status()`, build/maintenance job status,
`graph.loaded_graphs()`, and `graph.graph_runtime_status()` require read access
to every graph row they expose. `graph.projection_status()` and
`graph.build_resource_status()` require selected-graph `admin` access.
Cluster-wide generation counts and backend-local resource telemetry require a
graph-schema administrator (superuser or a role with `CREATE` on schema
`graph`).
Build and maintenance job status uses pinned catalog-only mediators that
capture the outer caller and authorize the selected or named graph. The raw
job tables are not a supported authorization bypass.
## 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._build_jobs
graph._maintenance_jobs
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` | Legacy 1.0 row-level security build boundary | Upgrade to the 1.1 behavior; `graph.allow_rls_tables` is retained only as a no-op compatibility setting |
| `55000` | `PG022` | Sync replay position predates a prune | Run `graph.build()` or `graph.vacuum()` for a fresh consistent base, then resume normal sync |
| `55000` | `PG023` | RLS relationship identity missing | Rebuild the projection so every relationship has a durable source-row identity |
| `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');
```