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.
This commit is contained in:
2026-10-20 02:20:26 +00:00
parent 903c591a06
commit d77409148c
3 changed files with 467 additions and 20 deletions
+178 -20
View File
@@ -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();
View File
+289
View File
@@ -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 <password vars>
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', '<itest@x>', '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()