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:
+178
-20
@@ -1,22 +1,180 @@
|
|||||||
-- Per-daemon least-privilege roles (audit critical finding 1, deployment half).
|
-- cayce-ops — least-privilege per-daemon roles (audit round 2, point 3)
|
||||||
-- The app-level chain + signatures are in code; this locks the DB layer.
|
--
|
||||||
-- Apply on the production Postgres; adjust the connection limits.
|
-- Apply with psql and MANDATORY password variables; the script refuses to run
|
||||||
CREATE ROLE cayce_scanner LOGIN PASSWORD 'set-from-env';
|
-- without them (no literal credentials):
|
||||||
CREATE ROLE cayce_executor LOGIN PASSWORD 'set-from-env';
|
--
|
||||||
CREATE ROLE cayce_maild LOGIN PASSWORD 'set-from-env';
|
-- psql -h 127.0.0.1 -U postgres -d cayce_ops -f deploy/roles.sql \
|
||||||
CREATE ROLE cayce_publisher LOGIN PASSWORD 'set-from-env';
|
-- -v scanner_password='…' -v executor_password='…' \
|
||||||
CREATE ROLE cayce_monitor LOGIN PASSWORD 'set-from-env';
|
-- -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;
|
\set ON_ERROR_STOP on
|
||||||
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;
|
|
||||||
|
|
||||||
-- sequences
|
\if :{?scanner_password}
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor;
|
\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();
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user