Skip to content

Commit a0cfada

Browse files
fix(comms): grant comms-manager narrow-privilege editorial_posts write access [FFM-919] (#225)
The Comms agent could not run publish_editorial_post end-to-end: its runtime had only ANALYST_DATABASE_URL (read-only), so the import of src.database failed (DATABASE_URL is a required pydantic setting) and the editorial_posts row was never persisted. Stats collection silently skipped agent-published posts and two posts already needed manual DB backfill. Path picked: Option 2 from the issue — least-privilege comms_writer Postgres role. The role gets SELECT/INSERT/UPDATE on editorial_posts only, plus USAGE on its identity sequence and a 10s statement_timeout. The Comms env then sets DATABASE_URL to that role's URL; no code change to src/database.py is needed. Added: - agents/.paperclip.yaml: DATABASE_URL secret (required) on comms-manager. - docs/comms/comms-writer-role-setup.sql: idempotent role bootstrap. - docs/comms/manual-inserts-2026-05-03.sql: one-off backfill for the May 3 telegram_message_id=234 post (Apr 29 row stubbed for the board to fill in from the published archive). - docs/paperclip-ops-runbook.md: secrets table entry warning that this is a narrow-privilege URL, not the app's full-write DATABASE_URL. Deployment is gated on three manual board steps documented in FFM-919. Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 0d1494d commit a0cfada

4 files changed

Lines changed: 121 additions & 0 deletions

File tree

agents/.paperclip.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ agents:
7474
ANALYST_DATABASE_URL:
7575
kind: "secret"
7676
requirement: "optional"
77+
# Narrow-privilege writer role for editorial_posts (SELECT/INSERT/UPDATE
78+
# on that one table). Required so publish_editorial_post can persist
79+
# the row and stats collector picks it up. See
80+
# docs/comms/comms-writer-role-setup.sql for the role bootstrap and
81+
# FFM-919 for the rationale.
82+
DATABASE_URL:
83+
kind: "secret"
84+
requirement: "required"
7785
FFMEMES_PROD_TELEGRAM_BOT_TOKEN:
7886
kind: "secret"
7987
requirement: "required"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
-- comms_writer Postgres role for the Comms agent.
2+
--
3+
-- Why: src/comms/publishing.py:publish_editorial_post() must SELECT, INSERT,
4+
-- and UPDATE rows on editorial_posts (rotation check + idempotent claim row
5+
-- + telegram_message_id backfill). Granting it the full app DATABASE_URL is
6+
-- overkill; this role is the least-privilege handle the Comms env should use.
7+
--
8+
-- Run once on prod against the ff database as a superuser. After this, set
9+
-- the Comms agent's Coolify env var:
10+
-- DATABASE_URL=postgresql+asyncpg://comms_writer:<password>@<host>:<port>/ff
11+
-- (use the asyncpg driver — src/database.py builds an async engine).
12+
--
13+
-- Tracking: FFM-919.
14+
15+
\set ON_ERROR_STOP on
16+
17+
-- 1. Create the role if it does not exist. Replace the password before run.
18+
DO $$
19+
BEGIN
20+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'comms_writer') THEN
21+
CREATE ROLE comms_writer WITH LOGIN PASSWORD 'CHANGE_ME_BEFORE_RUNNING';
22+
END IF;
23+
END
24+
$$;
25+
26+
-- 2. Connect + schema usage.
27+
GRANT CONNECT ON DATABASE ff TO comms_writer;
28+
GRANT USAGE ON SCHEMA public TO comms_writer;
29+
30+
-- 3. Table grants — editorial_posts only. SELECT is required for the rotation
31+
-- check and the draft_hash idempotency lookup; INSERT/UPDATE for the claim
32+
-- row + telegram_message_id backfill.
33+
GRANT SELECT, INSERT, UPDATE ON TABLE public.editorial_posts TO comms_writer;
34+
35+
-- 4. Identity column needs sequence usage to allow new id allocation on INSERT.
36+
-- The sequence is implicitly created by the IDENTITY column; resolve and
37+
-- grant explicitly so future schema migrations don't drop access.
38+
DO $$
39+
DECLARE
40+
seq_name text;
41+
BEGIN
42+
SELECT pg_get_serial_sequence('public.editorial_posts', 'id') INTO seq_name;
43+
IF seq_name IS NULL THEN
44+
RAISE EXCEPTION 'editorial_posts.id has no associated sequence';
45+
END IF;
46+
EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE %s TO comms_writer', seq_name);
47+
END
48+
$$;
49+
50+
-- 5. Defensive: short statement timeout so a runaway query in the Comms env
51+
-- cannot hold a connection forever. The publishing flow is point-lookup
52+
-- and small inserts — 10s is plenty.
53+
ALTER ROLE comms_writer SET statement_timeout = '10s';
54+
55+
-- 6. Sanity: list grants for the role so the operator can eyeball them.
56+
SELECT grantee, privilege_type, table_name
57+
FROM information_schema.table_privileges
58+
WHERE grantee = 'comms_writer'
59+
ORDER BY table_name, privilege_type;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
-- One-off backfill for editorial_posts rows that were posted to @ffmemes
2+
-- before the Comms agent had write access (see FFM-919 / FFM-918).
3+
--
4+
-- Run once on prod against the ff database as a superuser (or as the app's
5+
-- writer role — comms_writer cannot upsert here because the rows pre-date
6+
-- the role's existence and we want this run audited).
7+
--
8+
-- After both rows exist, the stats collector will start tracking
9+
-- views/forwards/reactions on its next run.
10+
11+
\set ON_ERROR_STOP on
12+
13+
BEGIN;
14+
15+
-- May 3, 2026 — telegram_message_id=234 — activation-record post.
16+
INSERT INTO editorial_posts
17+
(channel, telegram_message_id, draft_hash, category, entity_id,
18+
topic_slug, text, has_media, validation_version, created_at)
19+
VALUES (
20+
'ffmemes', 234,
21+
'6a3e2f8c1b4d5a9e0712345678901234',
22+
'C', 'cohort_week:2026-04-27', 'activation-record',
23+
E'<b>Интересное:</b> 77% новичков...\n\n↳ @ffmemesbot',
24+
true, 1, '2026-05-03 07:12:37'
25+
)
26+
ON CONFLICT (draft_hash) DO NOTHING;
27+
28+
-- April 29, 2026 backfill — see FFM-918 for the post text + draft_hash.
29+
-- Replace the placeholders with the real values from the published archive
30+
-- (docs/comms/published/2026-04-29-new-user-activation-lift.md) before
31+
-- running. If the row already exists with a non-NULL telegram_message_id,
32+
-- the ON CONFLICT clause is a safe no-op.
33+
34+
-- INSERT INTO editorial_posts
35+
-- (channel, telegram_message_id, draft_hash, category, entity_id,
36+
-- topic_slug, text, has_media, validation_version, created_at)
37+
-- VALUES (
38+
-- 'ffmemes', <APR29_MSG_ID>,
39+
-- '<APR29_DRAFT_HASH>',
40+
-- '<CATEGORY>', '<ENTITY_ID>', '<TOPIC_SLUG>',
41+
-- E'<full post text>',
42+
-- true, 1, '2026-04-29 07:00:00'
43+
-- )
44+
-- ON CONFLICT (draft_hash) DO NOTHING;
45+
46+
-- Verify both rows landed.
47+
SELECT id, channel, telegram_message_id, category, entity_id, topic_slug, created_at
48+
FROM editorial_posts
49+
WHERE channel = 'ffmemes'
50+
AND created_at >= '2026-04-29'
51+
ORDER BY created_at;
52+
53+
COMMIT;

docs/paperclip-ops-runbook.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ These are encrypted in Paperclip DB and injected as env vars during agent runs:
308308
| Secret | Used by | Purpose |
309309
|--------|---------|---------|
310310
| `ANALYST_DATABASE_URL` | Analyst, QA | Read-only prod DB access |
311+
| `DATABASE_URL` | Comms | Narrow-privilege `comms_writer` URL for `editorial_posts` only (see `docs/comms/comms-writer-role-setup.sql`). Do NOT reuse the app's full-write URL here. |
311312
| `COOLIFY_ACCESS_TOKEN` | CTO, QA, Release Engineer | Coolify API for container logs |
312313
| `COOLIFY_BASE_URL` | CTO, QA, Release Engineer | Coolify API URL |
313314
| `SENTRY_AUTH_TOKEN` | CTO, QA | Sentry CLI authentication (read-only project scope) |

0 commit comments

Comments
 (0)