feat: add read-only Postgres role for westside-streamlit (#5) #435
No reviewers
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/basketball-api!435
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "5-westside-streamlit-ro-role"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Creates the
westside_streamlit_roPostgres role with SELECT-only access to the 6 tables the westside-streamlit dashboard reads. Defense-in-depth for the read-only invariant — even ifapp.pyis modified to contain a write query, the DB role guarantees it cannot mutate state.Cross-repo ticket: the issue is filed on
forgejo_admin/westside-streamlit#5(project tracking) but the migration lives here because basketball-api owns the Alembic schema.Changes
alembic/versions/040_add_westside_streamlit_ro_role.py— new migration (filename040_because030_-039_are already taken;039is the current head).westside_streamlit_rowithLOGINand a password read from theWESTSIDE_STREAMLIT_RO_PASSWORDenv var (raises RuntimeError if missing).DO $$ ... CREATE/ALTER ROLEblock so re-running is safe.USAGEon schemapublicandSELECTonplayers,parents,teams,player_teams,orders,products— explicit list, notALL TABLES, so future tables are denied by default.REVOKE CREATE ON SCHEMA public FROM PUBLIC— required for PG <=14 and for test environments that reset schema withGRANT ALL TO PUBLIC. No-op on correctly configured PG 15+.downgrade()revokes all grants on tables, schema, sequences, thenDROP ROLE IF EXISTS— verified clean.tests/test_westside_streamlit_ro_role.py— 13 integration tests.ProgrammingErrorwithpermission denied.alembic upgradeto run against the test DB.Test Plan
Ran locally against
postgresql://basketball:basketball@localhost:5432/basketball_teston PG 18.3:End-to-end migration chain (
alembic stamp 039 && alembic upgrade head) verified grants:Downgrade verified clean:
ruff formatandruff checkboth clean on the two new files.Secrets
A fresh 32-character password has been generated and stored at
~/secrets/pal-e-services/westside-streamlit.env(mode 0600) asWESTSIDE_STREAMLIT_RO_PASSWORD=.... The password is NOT in git, NOT in the migration file, and NOT in this PR body. To run the migration against any environment, source that file first:The follow-up (westside-streamlit#4 Kustomize overlay) will pick this password up via a SOPS-managed Secret.
Review Checklist
040_), not a timestamp~/secrets/pal-e-services/westside-streamlit.envon hostALL TABLES— future tables denied by defaultdowngrade()drops role cleanly with no leftover grants — verified withalembic downgrade -1ruff formatandruff checkcleanWESTSIDE_STREAMLIT_RO_PASSWORDis wired into the basketball-api migration pipeline (Woodpecker secret) before deployRelated Notes
story-westside-streamlit-reachable— the user story this ticket traces toarch-dataflow-westside-streamlit— documents the read-only invariant this migration enforces at the DB layerincident-2026-04-10-pal-e-streamlit-public-funnel— defense-in-depth rationalefeedback_never_alter_prod_directly— why this goes through the Alembic pipeline, not a directpsqlreview-936-2026-04-10— scope review that drove the final shape of this ticketRelated
PR #435 Review
Fresh-context review. Read-only agent — no code changes, no merge.
DOMAIN REVIEW
Stack: Python / Alembic / SQLAlchemy / Postgres — DB-layer defense-in-depth migration.
Migration correctness (
alembic/versions/040_add_westside_streamlit_ro_role.py):040_prefix matching the convention inalembic/versions/(001–030 sequential, same zero-pad style). Good.upgrade()reads password fromWESTSIDE_STREAMLIT_RO_PASSWORD, raisesRuntimeErrorwith a useful message if unset. Good.password.replace("'", "''")is correct for Postgres single-quoted literals. CREATE/ALTER ROLE does not accept bind params, so inline interpolation is required here.DO $$ ... END $$block with CREATE/ALTER branch is safe to re-run.SELECTon exactly the 6 tables listed in the issue (players,parents,teams,player_teams,orders,products). Explicit list (notALL TABLES) — future tables denied by default. Good.USAGEon schema public — required for reference resolution.downgrade()revokes grants on the 6 named tables, then defensively doesREVOKE ALL PRIVILEGES ON ALL TABLES/SEQUENCES, thenDROP ROLE IF EXISTS. Clean.src/basketball_api/models.pylines 175, 196, 342, 385, 403;player_teamsjunction created in migration 019).Test correctness (
tests/test_westside_streamlit_ro_role.py):ProgrammingErrorwithpermission deniedormust be ownerin the message — robust against PG phrasing differences.ro_enginefixture builds a connection URL viamake_url(settings.database_url).set(username=..., password=...)— clean.alembic upgrade headhaving run. Self-contained. Good..woodpecker.yamlhas apostgres:16-alpineservice withBASKETBALL_DATABASE_URLpointing at the test DB, and the default superuser (basketball) canCREATE ROLE. Tests will execute in CI without additional secret plumbing.BLOCKERS
None.
NITS
Test fixture leaves
REVOKE CREATE ON SCHEMA public FROM PUBLICon teardown. The fixture mirrors the migration by revoking CREATE from PUBLIC inside setup, but the teardown only revokes role-specific grants and drops the role — it does not restore CREATE to PUBLIC. Withconftest.setup_dbrunningBase.metadata.create_allas thebasketballowner role (which still has CREATE as the schema owner), the pollution is inert for current tests. But it is a cross-test side effect that could surprise future test authors who add a non-owner role. Consider addingGRANT CREATE ON SCHEMA public TO PUBLICto the teardown, or leave a comment explaining why the asymmetry is intentional.REVOKE CREATE ON SCHEMA public FROM PUBLICin the migration is a global side effect. In prod, this revoke affects every role on the basketball DB, not just the new one. For the current basketball-api DB this is fine (thebasketballowner retains CREATE via ownership, and there are no other service roles on this DB that I can see). But the PR comment should call this out explicitly so a future operator adding another DB role on this cluster is not surprised. Not a blocker — PG 15+ does this by default, and the PR body does explain the rationale. Consider tightening the comment in the migration itself to note "this is global to thebasketballdatabase."test_ro_role_cannot_insertINSERT row referencesparent_id=1, tenant_id=1which will not exist in an empty test DB. Postgres does permission checks before execution, sopermission deniedfires before the FK check — verified correct — but a reader might wonder. A one-line comment ("FK values are irrelevant — permission check fires first") would help future maintainers.SOP COMPLIANCE
5-westside-streamlit-ro-role) — cross-repo issue, matches westside-streamlit#5# noqa: S105ruffcleanPROCESS OBSERVATIONS
westside-streamlit/app.pydrifts to contain a write query, the DB role guarantees immutability. This is the right layer.down_revision = "039"is correct. My local clone of~/basketball-apiis stale (head is 030). The PR body asserts 030–039 are taken and 039 is current head; the agent reports verifying this. Reviewer should confirm by runningalembic historyor checkinggit log --oneline alembic/versions/on the branch base before merge. If 039 is not actually the head, the migration will fail with "Can't locate revision identified by '039'".Specific concerns
VERDICT: APPROVED
3 nits, all non-blocking. Recommend addressing nit #1 (fixture teardown symmetry) before merge for hygiene, but not a gate. Reviewer must spot-check
down_revision = "039"against the actual head ofmainbefore merge.⚠️ Migration number collision discovered post-review
Post-review audit caught a blocker that the prior review missed (stale clone). This PR's branch was cut from an older
main:On
origin/mainright now:040_create_16u_local_queens_team.pyOn this PR branch:
040_add_westside_streamlit_ro_role.py(new), and040_create_16u_local_queens_team.pyis NOT present on the branchBoth migrations claim
040_anddown_revision = "039". Merging this PR as-is would create two Alembic heads, breakingalembic upgrade head.Required fix:
origin/mainalembic/versions/040_add_westside_streamlit_ro_role.py→041_add_westside_streamlit_ro_role.pyrevision = "040"→revision = "041"down_revision = "039"→down_revision = "040"(so it chains after040_create_16u_local_queens_team)alembic upgrade head+ the test suite to verify the chain is clean5-westside-streamlit-ro-roleOnce the fix is pushed, a fresh review agent will re-verify per
pr-review-loop.Not a fault of the prior review — the reviewer's clone was at head
030, and the dev agent's clone predated040_create_16u_local_queens_team.pylanding on main. Lesson for future: re-fetch from remote immediately before any migration-number decisions.c5bcf82440ed8795a970Fix round 1
Rebased onto latest main. Renumbered
040_add_westside_streamlit_ro_role.py->041_add_westside_streamlit_ro_role.pyto chain after040_create_16u_local_queens_team. Updated revision identifiers:revision = "040"->revision = "041"down_revision = "039"->down_revision = "040"alembic headsreturns single head:alembic historyshows clean linear chain:Tests not run locally —
basketball_apimodule not importable from /tmp clone (no editable install). CI will verify.No changes to upgrade/downgrade logic, grants, or password env var handling. Force-pushed with
--force-with-lease.Ready for re-review.
Round-2 fix pushed as
a507de2. Swept the 4 stale040_*references missed by the first renumber:tests/test_westside_streamlit_ro_role.py:175—test_migration_file_existshardcoded filename"040_add_westside_streamlit_ro_role.py"->"041_add_westside_streamlit_ro_role.py"alembic/versions/041_add_westside_streamlit_ro_role.py:3-4— module docstringRevision ID: 040->041,Revises: 039->040(therevision/down_revisioncode lines were already correct from round 1)tests/test_westside_streamlit_ro_role.py:3— module docstringmigration 040->migration 041tests/test_westside_streamlit_ro_role.py:51—ro_rolefixture docstringmigration 040->migration 041Post-fix grep sweep is clean:
Zero stale
040_add_westsidereferences remain. No upgrade/downgrade logic, grants list, or env var handling touched — only the 4 strings called out in the review. Ready for round-3 re-review.