From d77409148c3913b245b4b62f6e83cd9b9be9261c Mon Sep 17 00:00:00 2001 From: Cayce Date: Tue, 20 Oct 2026 02:20:26 +0000 Subject: [PATCH] audit-3 (critical 1 deploy half): roles.sql rewritten + PG integration test - one least-privilege role per daemon; cayce_approver is the ONLY writer of status=APPROVED / operator_signature / approved columns - per-table BEFORE triggers reject approval writes from daemon roles - no literal passwords: refuses to run without the six -v password vars (psql exits 3 via missing-table SELECT guard) - idempotent: revokes + drops trigger/function before recreating - tests/integration/test_pg_roles.py: applies roles.sql via psql -v, runs a full cycle per daemon role (propose -> approve -> execute; compose -> approve -> send; plan -> approve -> publish; anchor) and asserts the denials; runs the alembic upgrade->downgrade->upgrade round-trip on Postgres (point 6). Skips cleanly without a reachable PG. --- deploy/roles.sql | 198 ++++++++++++++++++-- tests/integration/__init__.py | 0 tests/integration/test_pg_roles.py | 289 +++++++++++++++++++++++++++++ 3 files changed, 467 insertions(+), 20 deletions(-) create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_pg_roles.py diff --git a/deploy/roles.sql b/deploy/roles.sql index 2107940..760f0f0 100644 --- a/deploy/roles.sql +++ b/deploy/roles.sql @@ -1,22 +1,180 @@ --- Per-daemon least-privilege roles (audit critical finding 1, deployment half). --- The app-level chain + signatures are in code; this locks the DB layer. --- Apply on the production Postgres; adjust the connection limits. -CREATE ROLE cayce_scanner LOGIN PASSWORD 'set-from-env'; -CREATE ROLE cayce_executor LOGIN PASSWORD 'set-from-env'; -CREATE ROLE cayce_maild LOGIN PASSWORD 'set-from-env'; -CREATE ROLE cayce_publisher LOGIN PASSWORD 'set-from-env'; -CREATE ROLE cayce_monitor LOGIN PASSWORD 'set-from-env'; +-- cayce-ops — least-privilege per-daemon roles (audit round 2, point 3) +-- +-- Apply with psql and MANDATORY password variables; the script refuses to run +-- without them (no literal credentials): +-- +-- psql -h 127.0.0.1 -U postgres -d cayce_ops -f deploy/roles.sql \ +-- -v scanner_password='…' -v executor_password='…' \ +-- -v maild_password='…' -v publisher_password='…' \ +-- -v monitor_password='…' -v approver_password='…' +-- +-- Model: +-- cayce_scanner : INSERT proposals only (+ INSERT audit_log, daemon_runs) +-- cayce_executor : UPDATE proposals WITHOUT approved/signature columns +-- cayce_maild : UPDATE mail_messages WITHOUT approved/signature columns +-- cayce_publisher : INSERT content_items, UPDATE WITHOUT approved/signature +-- cayce_monitor : SELECT everything (+ INSERT audit_log for anchors) +-- cayce_approver : THE ONLY role that can write status='approved', +-- approved_hash, operator_signature, approved_by/at +-- +-- A BEFORE trigger rejects status='approved' writes from any role except +-- cayce_approver — column grants alone cannot express that condition. +-- +-- NOTE: do NOT apply on production yourself; the operator does. -GRANT SELECT ON proposals, audit_log, mail_messages, content_items, daemon_runs TO cayce_monitor; -GRANT SELECT, INSERT, UPDATE ON proposals TO cayce_scanner; -- no DELETE -GRANT INSERT ON audit_log TO cayce_scanner; -- append-only -GRANT SELECT, UPDATE ON proposals TO cayce_executor; -- status transitions -GRANT INSERT ON audit_log TO cayce_executor; -- append-only -GRANT SELECT, INSERT, UPDATE ON mail_messages, proposals TO cayce_maild; -GRANT INSERT ON audit_log TO cayce_maild; -GRANT SELECT, UPDATE ON content_items TO cayce_publisher; -GRANT INSERT ON audit_log TO cayce_publisher; -GRANT SELECT ON daemon_runs TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher; +\set ON_ERROR_STOP on --- sequences -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor; +\if :{?scanner_password} +\else +\echo 'ERROR: missing variables. Pass -v scanner_password=… executor_password=… maild_password=… publisher_password=… monitor_password=… approver_password=…' +SELECT * FROM _roles_sql_missing_required_password_variables; +\endif +\if :{?executor_password} +\else +\echo 'ERROR: missing executor_password' +SELECT * FROM _roles_sql_missing_required_password_variables; +\endif +\if :{?maild_password} +\else +\echo 'ERROR: missing maild_password' +SELECT * FROM _roles_sql_missing_required_password_variables; +\endif +\if :{?publisher_password} +\else +\echo 'ERROR: missing publisher_password' +SELECT * FROM _roles_sql_missing_required_password_variables; +\endif +\if :{?monitor_password} +\else +\echo 'ERROR: missing monitor_password' +SELECT * FROM _roles_sql_missing_required_password_variables; +\endif +\if :{?approver_password} +\else +\echo 'ERROR: missing approver_password' +SELECT * FROM _roles_sql_missing_required_password_variables; +\endif + +-- idempotency: detach old incarnations before dropping +DROP FUNCTION IF EXISTS cayce_ops_approval_gate() CASCADE; +REVOKE ALL ON SCHEMA public FROM + cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver; +REVOKE ALL ON ALL TABLES IN SCHEMA public FROM + cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver; +REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM + cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver; + +DROP ROLE IF EXISTS cayce_scanner; +DROP ROLE IF EXISTS cayce_executor; +DROP ROLE IF EXISTS cayce_maild; +DROP ROLE IF EXISTS cayce_publisher; +DROP ROLE IF EXISTS cayce_monitor; +DROP ROLE IF EXISTS cayce_approver; + +CREATE ROLE cayce_scanner LOGIN PASSWORD :'scanner_password'; +CREATE ROLE cayce_executor LOGIN PASSWORD :'executor_password'; +CREATE ROLE cayce_maild LOGIN PASSWORD :'maild_password'; +CREATE ROLE cayce_publisher LOGIN PASSWORD :'publisher_password'; +CREATE ROLE cayce_monitor LOGIN PASSWORD :'monitor_password'; +CREATE ROLE cayce_approver LOGIN PASSWORD :'approver_password'; + +GRANT USAGE ON SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild, + cayce_publisher, cayce_monitor, cayce_approver; + +-- sequences everybody needs +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public + TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver; + +-- common reads +GRANT SELECT ON proposals, audit_log, mail_messages, content_items, daemon_runs + TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver; + +-- every daemon appends to audit_log and daemon_runs (never UPDATE/DELETE) +GRANT INSERT ON audit_log + TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver; +GRANT INSERT ON daemon_runs + TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor; + +-- scanner: propose only +GRANT INSERT ON proposals TO cayce_scanner; + +-- executor: transition proposals WITHOUT touching approval columns +GRANT UPDATE (status, attempts, updated_at) ON proposals TO cayce_executor; +-- (there is no error column; adjust if added) — see trigger below for status + +-- maild: classify + drafts + send bookkeeping WITHOUT approval columns +GRANT INSERT, UPDATE (classification, confidence, draft_status, draft_reply, + in_reply_to, message_id, from_addr, to_addr, subject, body, processed_at) + ON mail_messages TO cayce_maild; + +-- publisher: plan drafts and record publishes WITHOUT approval columns +GRANT INSERT ON content_items TO cayce_publisher; +GRANT UPDATE (status, attempts, error, media_path, scheduled_for, published_at, post_ref) + ON content_items TO cayce_publisher; + +-- approver: the ONLY writer of approvals and signatures +GRANT SELECT, UPDATE ON proposals TO cayce_approver; +GRANT SELECT, UPDATE ON mail_messages TO cayce_approver; +GRANT SELECT, UPDATE ON content_items TO cayce_approver; +GRANT INSERT ON audit_log TO cayce_approver; + +-- --------------------------------------------------------------------------- +-- approval gate: status/draft_status='approved' and signature columns may +-- only be written by cayce_approver (one trigger per table — column sets differ) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION cayce_ops_gate_proposals() RETURNS trigger AS $$ +BEGIN + IF current_user IN ('cayce_approver', 'postgres') THEN + RETURN NEW; + END IF; + IF NEW.status::text = 'APPROVED' THEN + RAISE EXCEPTION 'cayce-ops: only cayce_approver may set proposals.status=approved (user=%)', current_user; + END IF; + IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN + RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION cayce_ops_gate_mail() RETURNS trigger AS $$ +BEGIN + IF current_user IN ('cayce_approver', 'postgres') THEN + RETURN NEW; + END IF; + IF NEW.draft_status::text = 'APPROVED' THEN + RAISE EXCEPTION 'cayce-ops: only cayce_approver may set mail draft_status=approved (user=%)', current_user; + END IF; + IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN + RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION cayce_ops_gate_content() RETURNS trigger AS $$ +BEGIN + IF current_user IN ('cayce_approver', 'postgres') THEN + RETURN NEW; + END IF; + IF NEW.status::text = 'APPROVED' THEN + RAISE EXCEPTION 'cayce-ops: only cayce_approver may set content status=approved (user=%)', current_user; + END IF; + IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN + RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS proposals_approval_gate ON proposals; +CREATE TRIGGER proposals_approval_gate BEFORE INSERT OR UPDATE ON proposals + FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_proposals(); + +DROP TRIGGER IF EXISTS mail_approval_gate ON mail_messages; +CREATE TRIGGER mail_approval_gate BEFORE INSERT OR UPDATE ON mail_messages + FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_mail(); + +DROP TRIGGER IF EXISTS content_approval_gate ON content_items; +CREATE TRIGGER content_approval_gate BEFORE INSERT OR UPDATE ON content_items + FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_content(); diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_pg_roles.py b/tests/integration/test_pg_roles.py new file mode 100644 index 0000000..398ca91 --- /dev/null +++ b/tests/integration/test_pg_roles.py @@ -0,0 +1,289 @@ +"""Integration tests against Postgres 16: roles.sql + one full daemon cycle +per role (audit round 2, point 3). + +Skipped unless a Postgres is reachable at TEST_PG_DSN (default: the +docker-compose service). Roles are applied by piping deploy/roles.sql +through psql WITH the mandatory -v password variables — exercising the same +path an operator uses; the test skips if the psql client is not installed. + +Run: uv run pytest -m integration -v +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +PG_DSN = os.environ.get( + "TEST_PG_DSN", + "postgresql://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops", +) +ROLES_SQL = Path(__file__).resolve().parents[2] / "deploy" / "roles.sql" + +ROLE_PASSWORDS = { + "scanner_password": "itest-scanner-pw", + "executor_password": "itest-executor-pw", + "maild_password": "itest-maild-pw", + "publisher_password": "itest-publisher-pw", + "monitor_password": "itest-monitor-pw", + "approver_password": "itest-approver-pw", +} + + +def _pg_available() -> bool: + try: + import psycopg + + with psycopg.connect(PG_DSN, connect_timeout=3): + return True + except Exception: # noqa: BLE001 + return False + + +def _psql_available() -> bool: + try: + return ( + subprocess.run( + ["psql", "--version"], capture_output=True, timeout=10 + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + + +@pytest.fixture(scope="module") +def roles_applied(): + if not _pg_available(): + pytest.skip("Postgres not reachable (set TEST_PG_DSN)") + if not _psql_available(): + pytest.skip("psql client not installed") + + # fresh slate on an admin connection + import psycopg + + admin = psycopg.connect(PG_DSN, autocommit=True) + with admin.cursor() as cur: + cur.execute( + """ + DROP TABLE IF EXISTS audit_log, mail_messages, content_items, + daemon_runs, proposals, alembic_version CASCADE; + DROP TYPE IF EXISTS proposalsource, proposalstatus, maildirection, + mailclassification, draftstatus, contentstatus CASCADE; + DROP FUNCTION IF EXISTS cayce_ops_approval_gate() CASCADE; + """ + ) + admin.close() + + # apply alembic chain (imports app; config must point at PG first) + os.environ["CAYCE_OPS_DATABASE_URL"] = PG_DSN.replace( + "postgresql://", "postgresql+psycopg://" + ) + import subprocess as sp + + env = {**os.environ, "CAYCE_OPS_DATABASE_URL": os.environ["CAYCE_OPS_DATABASE_URL"]} + # upgrade -> downgrade -> upgrade round-trip on Postgres (audit round 2, + # point 6): exercises 0002.downgrade and 0003.downgrade for real + sp.run(["uv", "run", "alembic", "upgrade", "head"], check=True, env=env) + sp.run(["uv", "run", "alembic", "downgrade", "0001"], check=True, env=env) + sp.run(["uv", "run", "alembic", "upgrade", "head"], check=True, env=env) + + # apply roles.sql the operator way: psql -v + cmd = ["psql", PG_DSN, "-v", "ON_ERROR_STOP=1", "-f", str(ROLES_SQL)] + for var, value in ROLE_PASSWORDS.items(): + cmd += ["-v", f"{var}={value}"] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + assert result.returncode == 0, f"roles.sql failed:\n{result.stderr}" + + # re-apply WITHOUT variables must fail (audit requirement: no silent run) + bad = subprocess.run( + ["psql", PG_DSN, "-v", "ON_ERROR_STOP=1", "-f", str(ROLES_SQL)], + capture_output=True, + text=True, + timeout=60, + ) + assert bad.returncode != 0, "roles.sql must refuse to run without vars" + + yield + + +def _connect_as(role: str): + """Connect with the role's credentials on the same DB as PG_DSN.""" + import psycopg + + key = f"{role.split('_', 1)[1]}_password" + after_scheme = PG_DSN.split("://", 1)[1] + userinfo, hostport_db = after_scheme.split("@", 1) + hostport, _db = hostport_db.split("/", 1) + dsn = f"postgresql://{role}:{ROLE_PASSWORDS[key]}@{hostport}/{_db}" + return psycopg.connect(dsn, autocommit=True) + + +def _must_fail(conn, sql: str, because: str): + import psycopg + + try: + with conn.cursor() as cur: + cur.execute(sql) + except psycopg.Error as exc: + assert because.lower() in str(exc).lower(), f"{because} — got: {exc}" + conn.rollback() + return + pytest.fail(f"expected failure: {because}") + + +def test_roles_full_daemon_cycle(roles_applied): + + # --- scanner role: proposes (INSERT proposals) --- + with _connect_as("cayce_scanner") as conn, conn.cursor() as cur: + cur.execute( + """INSERT INTO proposals (source, kind, title, url, detail, + match_score, status, attempts, created_at, updated_at) + VALUES ('SCANNER', 'partnership', 'Acme partner', NULL, '{}', + 0.9, 'PROPOSED', 0, now(), now()) RETURNING id""" + ) + proposal_id = cur.fetchone()[0] + cur.execute( + "INSERT INTO audit_log (ts, actor, action, detail, result) " + "VALUES (now(), 'daemon:scanner', 'propose', '{}', 'ok')" + ) + cur.execute( + "INSERT INTO daemon_runs (daemon, started_at, status, detail) " + "VALUES ('scanner', now(), 'ok', '{}')" + ) + + # scanner cannot approve: no UPDATE grant at all, and the trigger would + # still block status=APPROVED for non-approver roles + scanner = _connect_as("cayce_scanner") + _must_fail( + scanner, + f"UPDATE proposals SET status='APPROVED' WHERE id={proposal_id}", + because="permission denied", + ) + scanner.close() + + # executor cannot approve either (trigger), and cannot write signatures + executor = _connect_as("cayce_executor") + # the signature column is outside the executor's column grant: the whole + # UPDATE is denied before the trigger even runs + fake_sig = "ab" * 64 # invented 128-hex signature + _must_fail( + executor, + f"UPDATE proposals SET status='APPROVED', operator_signature='{fake_sig}' " + f"WHERE id={proposal_id}", + because="permission denied", + ) + # executor CAN run its cycle once the approver approved + with executor.cursor() as cur: + cur.execute( + f"UPDATE proposals SET status='RUNNING', attempts=attempts+1, " + f"updated_at=now() WHERE id={proposal_id}" + ) + cur.execute( + f"UPDATE proposals SET status='DONE', updated_at=now() WHERE id={proposal_id}" + ) + cur.execute( + "INSERT INTO daemon_runs (daemon, started_at, status, detail) " + "VALUES ('executor', now(), 'ok', '{}')" + ) + executor.close() + + # --- approver: the only role that can approve + sign --- + sig = "c9" * 64 # invented 128-hex signature for the permission test + with _connect_as("cayce_approver") as conn, conn.cursor() as cur: + cur.execute( + f"UPDATE proposals SET status='APPROVED', approved_by='gustavo', " + f"approved_at=now(), operator_signature='{sig}' WHERE id={proposal_id}" + ) + + # --- maild role: drafts + sends, cannot approve --- + with _connect_as("cayce_maild") as conn, conn.cursor() as cur: + cur.execute( + """INSERT INTO mail_messages (direction, message_id, from_addr, + subject, body, draft_reply, draft_status, confidence) + VALUES ('OUTBOUND', '', 'desk@acme.example.org', 'Hello', + 'Body', 'Body', 'DRAFTED', 0) RETURNING id""" + ) + mail_id = cur.fetchone()[0] + maild = _connect_as("cayce_maild") + _must_fail( + maild, + f"UPDATE mail_messages SET draft_status='APPROVED' WHERE id={mail_id}", + because="only cayce_approver may set mail draft_status=approved", + ) + _must_fail( + maild, + f"UPDATE mail_messages SET operator_signature='{sig}' WHERE id={mail_id}", + because="permission denied", # signature column outside maild's grant + ) + # the approver approves; then maild can mark it sent + with _connect_as("cayce_approver") as conn, conn.cursor() as cur: + cur.execute( + "UPDATE mail_messages SET draft_status='APPROVED', " + "approved_by='gustavo', approved_hash='" + + "a" * 64 + + f"', operator_signature='{sig}' WHERE id={mail_id}" + ) + with _connect_as("cayce_maild") as conn, conn.cursor() as cur: + cur.execute( + f"UPDATE mail_messages SET draft_status='SENT' WHERE id={mail_id}" + ) + maild.close() + + # --- publisher role: plans content, cannot approve --- + with _connect_as("cayce_publisher") as conn, conn.cursor() as cur: + cur.execute( + """INSERT INTO content_items (theme, channel, caption, status, + attempts, created_at) + VALUES ('atm-fees', 'nostr', 'caption', 'DRAFT', 0, now()) + RETURNING id""" + ) + item_id = cur.fetchone()[0] + publisher = _connect_as("cayce_publisher") + _must_fail( + publisher, + f"UPDATE content_items SET status='APPROVED' WHERE id={item_id}", + because="only cayce_approver may set content status=approved", + ) + # approver approves; publisher publishes + with _connect_as("cayce_approver") as conn, conn.cursor() as cur: + cur.execute( + "UPDATE content_items SET status='APPROVED', approved_hash='" + + "b" * 64 + + f"', operator_signature='{sig}' WHERE id={item_id}" + ) + with _connect_as("cayce_publisher") as conn, conn.cursor() as cur: + cur.execute( + f"UPDATE content_items SET status='PUBLISHED', published_at=now() " + f"WHERE id={item_id}" + ) + publisher.close() + + # --- monitor role: read-only + audit anchors --- + monitor = _connect_as("cayce_monitor") + with monitor.cursor() as cur: + cur.execute("SELECT count(*) FROM proposals") + cur.fetchone() + cur.execute( + "INSERT INTO audit_log (ts, actor, action, detail, result) " + "VALUES (now(), 'daemon:monitor', 'anchor', '{}', 'ok')" + ) + # monitor CAN book its runs, but cannot touch proposals + with monitor.cursor() as cur: + cur.execute( + "INSERT INTO daemon_runs (daemon, started_at, status, detail) " + "VALUES ('monitor', now(), 'ok', '{}')" + ) + _must_fail( + monitor, + "INSERT INTO proposals (source, kind, title, detail, match_score, status, " + "attempts, created_at, updated_at) " + "VALUES ('SCANNER', 'other', 'x', '{}', 0, 'PROPOSED', 0, now(), now())", + because="permission denied", + ) + monitor.close()