Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ REGRESS = scan \
index \
analyze \
graph_generation \
graph_generation_security \
name_validation \
jsonb_operators \
list_comprehension \
Expand Down
129 changes: 129 additions & 0 deletions regress/expected/graph_generation_security.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/* Graph generators must respect table INSERT privileges and RLS. */
LOAD 'age';
SET search_path = ag_catalog, public;
SELECT create_graph('gen_security');
NOTICE: graph "gen_security" has been created
create_graph
--------------

(1 row)

SELECT * FROM cypher('gen_security',
$$CREATE (:Node {key:'root'})-[:LINK]->(:Node {key:'target'})$$)
AS (v agtype);
v
---
(0 rows)

CREATE ROLE gen_reader;
GRANT USAGE ON SCHEMA ag_catalog, gen_security TO gen_reader;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA gen_security TO gen_reader;
SET ROLE gen_reader;
SELECT create_complete_graph('gen_security', 2, 'LINK', 'Node');
ERROR: permission denied for table Node
SELECT age_create_barbell_graph('gen_security', 3, 0, 'Node', NULL,
'LINK', NULL);
ERROR: permission denied for table Node
RESET ROLE;
SELECT count(*) FROM gen_security."Node";
count
-------
2
(1 row)

SELECT count(*) FROM gen_security."LINK";
count
-------
1
(1 row)

/* A missing edge INSERT grant must fail before any vertex is written. */
GRANT INSERT ON gen_security."Node" TO gen_reader;
SET ROLE gen_reader;
SELECT create_complete_graph('gen_security', 2, 'LINK', 'Node');
ERROR: permission denied for table LINK
RESET ROLE;
SELECT count(*) FROM gen_security."Node";
count
-------
2
(1 row)

SELECT count(*) FROM gen_security."LINK";
count
-------
1
(1 row)

/* Column-level INSERT grants on every written column are sufficient. */
CREATE ROLE gen_columns;
GRANT USAGE ON SCHEMA ag_catalog, gen_security TO gen_columns;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA gen_security TO gen_columns;
GRANT INSERT (id, properties) ON gen_security."Node" TO gen_columns;
GRANT INSERT (id, start_id, end_id, properties)
ON gen_security."LINK" TO gen_columns;
SET ROLE gen_columns;
SELECT create_complete_graph('gen_security', 2, 'LINK', 'Node');
create_complete_graph
-----------------------

(1 row)

RESET ROLE;
SELECT count(*) FROM gen_security."Node";
count
-------
4
(1 row)

SELECT count(*) FROM gen_security."LINK";
count
-------
2
(1 row)

/* Direct tuple insertion cannot apply the graph tables' INSERT policies. */
GRANT INSERT ON ALL TABLES IN SCHEMA gen_security TO gen_reader;
ALTER TABLE gen_security."_ag_label_vertex" ENABLE ROW LEVEL SECURITY;
ALTER TABLE gen_security."_ag_label_vertex" FORCE ROW LEVEL SECURITY;
ALTER TABLE gen_security."Node" ENABLE ROW LEVEL SECURITY;
ALTER TABLE gen_security."Node" FORCE ROW LEVEL SECURITY;
ALTER TABLE gen_security."_ag_label_edge" ENABLE ROW LEVEL SECURITY;
ALTER TABLE gen_security."_ag_label_edge" FORCE ROW LEVEL SECURITY;
ALTER TABLE gen_security."LINK" ENABLE ROW LEVEL SECURITY;
ALTER TABLE gen_security."LINK" FORCE ROW LEVEL SECURITY;
SET ROLE gen_reader;
SELECT create_complete_graph('gen_security', 2, 'LINK', 'Node');
ERROR: graph generation is not supported with row-level security
HINT: Use Cypher CREATE instead.
SELECT age_create_barbell_graph('gen_security', 3, 0, 'Node', NULL,
'LINK', NULL);
ERROR: graph generation is not supported with row-level security
HINT: Use Cypher CREATE instead.
RESET ROLE;
SELECT count(*) FROM gen_security."Node";
count
-------
4
(1 row)

SELECT count(*) FROM gen_security."LINK";
count
-------
2
(1 row)

SELECT drop_graph('gen_security', true);
NOTICE: drop cascades to 4 other objects
DETAIL: drop cascades to table gen_security._ag_label_vertex
drop cascades to table gen_security._ag_label_edge
drop cascades to table gen_security."Node"
drop cascades to table gen_security."LINK"
NOTICE: graph "gen_security" has been dropped
drop_graph
------------

(1 row)

DROP OWNED BY gen_reader, gen_columns;
DROP ROLE gen_reader, gen_columns;
65 changes: 65 additions & 0 deletions regress/sql/graph_generation_security.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* Graph generators must respect table INSERT privileges and RLS. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please move these cases into regress/sql/graph_generation.sql and its expected output. It already covers both generators; there is no need for a separate test file and Makefile entry.

LOAD 'age';
SET search_path = ag_catalog, public;

SELECT create_graph('gen_security');
SELECT * FROM cypher('gen_security',
$$CREATE (:Node {key:'root'})-[:LINK]->(:Node {key:'target'})$$)
AS (v agtype);

CREATE ROLE gen_reader;
GRANT USAGE ON SCHEMA ag_catalog, gen_security TO gen_reader;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA gen_security TO gen_reader;

SET ROLE gen_reader;
SELECT create_complete_graph('gen_security', 2, 'LINK', 'Node');
SELECT age_create_barbell_graph('gen_security', 3, 0, 'Node', NULL,
'LINK', NULL);
RESET ROLE;
SELECT count(*) FROM gen_security."Node";
SELECT count(*) FROM gen_security."LINK";

/* A missing edge INSERT grant must fail before any vertex is written. */
GRANT INSERT ON gen_security."Node" TO gen_reader;
SET ROLE gen_reader;
SELECT create_complete_graph('gen_security', 2, 'LINK', 'Node');
RESET ROLE;
SELECT count(*) FROM gen_security."Node";
SELECT count(*) FROM gen_security."LINK";

/* Column-level INSERT grants on every written column are sufficient. */
CREATE ROLE gen_columns;
GRANT USAGE ON SCHEMA ag_catalog, gen_security TO gen_columns;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA gen_security TO gen_columns;
GRANT INSERT (id, properties) ON gen_security."Node" TO gen_columns;
GRANT INSERT (id, start_id, end_id, properties)
ON gen_security."LINK" TO gen_columns;

SET ROLE gen_columns;
SELECT create_complete_graph('gen_security', 2, 'LINK', 'Node');
RESET ROLE;
SELECT count(*) FROM gen_security."Node";
SELECT count(*) FROM gen_security."LINK";

/* Direct tuple insertion cannot apply the graph tables' INSERT policies. */
GRANT INSERT ON ALL TABLES IN SCHEMA gen_security TO gen_reader;
ALTER TABLE gen_security."_ag_label_vertex" ENABLE ROW LEVEL SECURITY;
ALTER TABLE gen_security."_ag_label_vertex" FORCE ROW LEVEL SECURITY;
ALTER TABLE gen_security."Node" ENABLE ROW LEVEL SECURITY;
ALTER TABLE gen_security."Node" FORCE ROW LEVEL SECURITY;
ALTER TABLE gen_security."_ag_label_edge" ENABLE ROW LEVEL SECURITY;
ALTER TABLE gen_security."_ag_label_edge" FORCE ROW LEVEL SECURITY;
ALTER TABLE gen_security."LINK" ENABLE ROW LEVEL SECURITY;
ALTER TABLE gen_security."LINK" FORCE ROW LEVEL SECURITY;

SET ROLE gen_reader;
SELECT create_complete_graph('gen_security', 2, 'LINK', 'Node');
SELECT age_create_barbell_graph('gen_security', 3, 0, 'Node', NULL,
'LINK', NULL);
RESET ROLE;
SELECT count(*) FROM gen_security."Node";
SELECT count(*) FROM gen_security."LINK";

SELECT drop_graph('gen_security', true);
DROP OWNED BY gen_reader, gen_columns;
DROP ROLE gen_reader, gen_columns;
47 changes: 47 additions & 0 deletions src/backend/utils/graph_generation.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,42 @@
#include "postgres.h"

#include "access/genam.h"
#include "catalog/pg_namespace.h"
#include "miscadmin.h"
#include "commands/graph_commands.h"
#include "utils/acl.h"
#include "utils/load/age_load.h"
#include "utils/rls.h"

/* The generators insert tuples directly, without the executor's ACL/RLS checks. */
static void check_generation_insert(Oid relid, int columns)
{
if (pg_class_aclcheck(relid, GetUserId(), ACL_INSERT) != ACLCHECK_OK)
{
int attnum;

/* INSERT on every written column is also sufficient in PostgreSQL. */
for (attnum = 1; attnum <= columns; attnum++)
{
AclResult aclresult = pg_attribute_aclcheck(relid, attnum,
GetUserId(), ACL_INSERT);

if (aclresult != ACLCHECK_OK)
{
aclcheck_error(aclresult, OBJECT_TABLE, get_rel_name(relid));
}
}
}

/* These direct writes cannot evaluate INSERT WITH CHECK policies. */
if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please lock both label tables before checking RLS and retain the locks through the writes. There is no table lock here, so RLS can change while the generator waits to insert.

Reproduced on PostgreSQL 18.3:

Setup as an administrator:

CREATE EXTENSION IF NOT EXISTS age;
LOAD 'age';
SET search_path = ag_catalog, public;
SELECT create_graph('rls_repro');
SELECT create_vlabel('rls_repro', 'Node');
SELECT create_elabel('rls_repro', 'LINK');
CREATE ROLE rls_writer NOSUPERUSER NOBYPASSRLS;
GRANT USAGE ON SCHEMA ag_catalog, rls_repro TO rls_writer;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA rls_repro TO rls_writer;
GRANT INSERT ON rls_repro."Node", rls_repro."LINK" TO rls_writer;

Session A:

BEGIN;
LOCK TABLE rls_repro."Node" IN ACCESS EXCLUSIVE MODE;

Session B — leave the SELECT waiting:

LOAD 'age';
SET search_path = ag_catalog, public;
SET ROLE rls_writer;
SELECT create_complete_graph('rls_repro', 2, 'LINK', 'Node');

Then in session A:

ALTER TABLE rls_repro."Node" ENABLE ROW LEVEL SECURITY;
ALTER TABLE rls_repro."Node" FORCE ROW LEVEL SECURITY;
COMMIT;

Session B succeeds and commits two vertices and one edge, despite RLS having no INSERT policy. A normal SQL INSERT under the same lock/RLS sequence correctly fails. Please cover this with a two-session regression.

{
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("graph generation is not supported with row-level security"),
errhint("Use Cypher CREATE instead.")));
}
}


int64 get_nextval_internal(graph_cache_data* graph_cache,
Expand Down Expand Up @@ -169,6 +203,19 @@ Datum create_complete_graph(PG_FUNCTION_ARGS)
vtx_seq_id = get_relname_relid(vtx_seq_name_str, nsp_id);
edge_seq_id = get_relname_relid(edge_seq_name_str, nsp_id);

/* Check both labels before consuming sequence values or inserting rows. */
{
AclResult aclresult = object_aclcheck(NamespaceRelationId, nsp_id,
GetUserId(), ACL_USAGE);

if (aclresult != ACLCHECK_OK)
{
aclcheck_error(aclresult, OBJECT_SCHEMA, graph_name_str);
}
}
check_generation_insert(get_label_relation(vtx_name_str, graph_oid), 2);
check_generation_insert(get_label_relation(edge_name_str, graph_oid), 4);

props = create_empty_agtype();

/* Creating vertices*/
Expand Down
15 changes: 15 additions & 0 deletions tools/graph-generation-security/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Graph generator permission reproducer

Run this only against a disposable PostgreSQL database with AGE installed. The
test creates and removes synthetic graphs and login roles. The generated logins
need trust authentication for a separate connection.

```sh
python3 -m pip install 'psycopg[binary]'
python3 reproduce.py --host 127.0.0.1 --dbname age_security_audit
```

Each case compares a denied SQL `INSERT` with a graph generator call and checks
the committed row counts from a new connection. `bypass: true` indicates the
generator wrote rows that the login could not insert with SQL. The RLS cases
grant table `INSERT` but force RLS with no `INSERT` policy.
104 changes: 104 additions & 0 deletions tools/graph-generation-security/reproduce.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These checks can go in the SQL regression tests using SET ROLE. The Python script isn’t run by CI and only prints results.

"""Reproduce graph generator INSERT/RLS bypasses in a disposable AGE database."""

import argparse
import json
import uuid

import psycopg


def outcome(conn, sql):
try:
conn.execute(sql)
return {"sqlstate": "00000"}
except psycopg.Error as exc:
return {"sqlstate": exc.sqlstate, "message": exc.diag.message_primary}


def counts(conn, graph):
return {
label: conn.execute(f'SELECT count(*) FROM {graph}."{label}"').fetchone()[0]
for label in ("Node", "LINK")
}


def run_case(connection, function, rls):
suffix = uuid.uuid4().hex[:10]
graph = f"generator_repro_{suffix}"
role = f"generator_reader_{suffix}"
report = {"function": function, "rls": rls}

with psycopg.connect(user=connection["admin_user"], host=connection["host"],
dbname=connection["dbname"], autocommit=True) as admin:
admin.execute("SET search_path=ag_catalog,public")
admin.execute(f"CREATE ROLE {role} LOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT")
try:
admin.execute(f"SELECT ag_catalog.create_graph('{graph}')")
admin.execute(f"SELECT * FROM ag_catalog.cypher('{graph}', "
"$cy$CREATE (:Node {key:'root'})-[:LINK]->"
"(:Node {key:'target'})$cy$) AS (v agtype)")
admin.execute(f"GRANT USAGE ON SCHEMA ag_catalog,{graph} TO {role}")
admin.execute(f"GRANT USAGE ON ALL SEQUENCES IN SCHEMA {graph} TO {role}")
if rls:
admin.execute(f"GRANT INSERT ON ALL TABLES IN SCHEMA {graph} TO {role}")
for label in ("_ag_label_vertex", "Node", "_ag_label_edge", "LINK"):
admin.execute(f'ALTER TABLE {graph}."{label}" ENABLE ROW LEVEL SECURITY')
admin.execute(f'ALTER TABLE {graph}."{label}" FORCE ROW LEVEL SECURITY')
report["before"] = counts(admin, graph)

with psycopg.connect(user=role, host=connection["host"],
dbname=connection["dbname"], autocommit=True) as reader:
reader.execute("SET search_path=ag_catalog,public")
reader.execute("SET statement_timeout='2s'")
report["identity"] = reader.execute(
"SELECT rolsuper,rolbypassrls FROM pg_roles WHERE rolname=current_user"
).fetchone()
report["table_insert"] = {
label: reader.execute(
f"SELECT has_table_privilege(current_user,'{graph}.\"{label}\"','INSERT')"
).fetchone()[0]
for label in ("Node", "LINK")
}
report["direct_insert"] = outcome(
reader, f'INSERT INTO {graph}."Node" DEFAULT VALUES')
if function == "create_complete_graph":
call = (f"SELECT ag_catalog.create_complete_graph('{graph}', "
"2, 'LINK', 'Node')")
else:
call = (f"SELECT ag_catalog.age_create_barbell_graph('{graph}', "
"3, 0, 'Node', NULL, 'LINK', NULL)")
report["generator"] = outcome(reader, call)

# A separate connection checks committed rows after the caller exits.
with psycopg.connect(user=connection["admin_user"],
host=connection["host"],
dbname=connection["dbname"], autocommit=True) as verify:
report["after"] = counts(verify, graph)
report["bypass"] = (report["direct_insert"]["sqlstate"] == "42501"
and report["generator"]["sqlstate"] == "00000"
and report["after"] != report["before"])
finally:
admin.execute(f"SELECT ag_catalog.drop_graph('{graph}', true)")
admin.execute(f"DROP OWNED BY {role}")
admin.execute(f"DROP ROLE {role}")
return report


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", required=True)
parser.add_argument("--dbname", required=True)
parser.add_argument("--admin-user", default="postgres")
args = parser.parse_args()
connection = vars(args)
results = [
run_case(connection, function, rls)
for rls in (False, True)
for function in ("create_complete_graph", "age_create_barbell_graph")
]
print(json.dumps(results, indent=2))


if __name__ == "__main__":
main()