Ticket: Create westside_ops_reader Postgres role + GRANT allowlist
Ticket: westside_ops_reader Postgres role + GRANT allowlist
Story:
Architecture:
Labels:
Blocks: k8s overlay ticket (needs DB URL + password to populate the SOPS secret)
Blocked by: Streamlit spike ticket
story-westside-ops-spreadsheet-accessArchitecture:
arch-domain-westside-ops (Key Decisions: defense-in-depth at role level)Labels:
story:spreadsheet-access,arch:postgres-role,type:infra,track:backend,scope:plannedBlocks: k8s overlay ticket (needs DB URL + password to populate the SOPS secret)
Blocked by: Streamlit spike ticket
Purpose
Create a dedicated Postgres role inside basketball-api's existing Postgres pod that is the only credential westside-ops ever uses to talk to the database. The role has explicit GRANTs on 14 tables and cannot read
oauth_tokens, password_reset_tokens, or outbox. This is the load-bearing security control: even if the Streamlit app had a bug that tried to execute arbitrary SQL, those three tables would return permission denied.basketball-api is hands-off per convention — this is the only touch we make, and it's an additive SQL migration run once via
kubectl exec. No code changes to basketball-api.Scope
- Generate a strong password for the new role (32+ random bytes). Store locally at
~/secrets/westside-ops/reader-db-passwordfollowing the existing~/secrets/{service}/convention documented in~/secrets/README.md - Write a one-shot SQL file containing the CREATE ROLE + GRANT statements (see exact SQL below)
- Apply via
kubectl exec -n basketball-api <postgres-pod> -- psql -U basketball -d basketball -f -(piping the SQL file in) - Verify the allowlist works: connect as the new role and run a SELECT on every allowed table; confirm it succeeds
- Verify the blocklist works: connect as the new role and attempt
SELECT * FROM oauth_tokens,SELECT * FROM password_reset_tokens,SELECT * FROM outbox; confirm all three returnpermission denied
Exact SQL
Verification
Acceptance Criteria
- [ ]
~/secrets/westside-ops/reader-db-passwordexists with 32+ bytes of entropy, not committed anywhere - [ ]
\du westside_ops_readerin the basketball-api postgres pod shows the role exists with LOGIN attribute - [ ] All 14 allowed tables return a row count when queried as
westside_ops_reader - [ ] All 3 forbidden tables return
permission deniedwhen queried aswestside_ops_reader - [ ] SQL file archived at
~/westside-ops/migrations/001-create-reader-role.sql(committed later with the repo bootstrap, for reference) - [ ] Migration is idempotent — running the SQL twice does not error
Files touched
~/secrets/westside-ops/reader-db-password(new, local-only)- basketball-api Postgres state (CREATE ROLE + GRANTs)
- Eventually:
~/westside-ops/migrations/001-create-reader-role.sql(committed with repo bootstrap ticket)
Rollback
Requires disconnecting any active connections first. No data is affected by dropping the role.
Out of scope
- Row-Level Security (RLS) policies — deferred to a later story if Marcus's personal-data view ever needs "Marcus sees only his row" semantics. For v1 operator use, table-level GRANTs are sufficient.
- Read-write (INSERT/UPDATE/DELETE) grants — v1 is read-only per the user story. Write access is a future expansion ticket.
- Any changes to basketball-api's application code, alembic migrations, or Python files.
Dependencies
Streamlit spike ticket must pass veto gate first. Otherwise nothing — this ticket is independent of repo/CI/overlay work and can execute in parallel with them.