From cbbe3dab871ae5273ef3149116f2354087717ef2 Mon Sep 17 00:00:00 2001 From: david kiania Date: Wed, 10 Jun 2026 12:33:53 +0300 Subject: [PATCH] feat(db): dedicated read-only dashboard_ro role + repoint staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the grafana_ro reuse with a purpose-built least-privilege login role that can serve the FULL dashboard_api read surface — so it backs the staging instance now and can take over the live prod connection later (stage 2). scripts/dashboard_ro_role.sql (run as postgres, password-free in repo): - CREATE ROLE dashboard_ro LOGIN, read-only - SELECT on reporting.* + tracksolid.*; explicit SELECT on the reporting.v_trips MATERIALIZED VIEW (not covered by GRANT ON ALL TABLES) - EXECUTE on reporting.fn_* map functions - ALTER DEFAULT PRIVILEGES so future objects are auto-readable ("dynamic") scripts/bootstrap_dashboard_ro.sh: - generates the password into ~/.dashboard_ro.pw (0600), never printed - applies the DDL via docker exec psql -U postgres -v ro_pw=... deploy_dashboard_api_staging.sh: build DATABASE_URL from dashboard_ro + ~/.dashboard_ro.pw instead of grafana_ro. Migrations 17/18 (already applied) are left intact. Not yet executed on host. Co-Authored-By: Claude Opus 4.8 --- deploy_dashboard_api_staging.sh | 16 ++++++----- scripts/bootstrap_dashboard_ro.sh | 38 ++++++++++++++++++++++++++ scripts/dashboard_ro_role.sql | 45 +++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) create mode 100755 scripts/bootstrap_dashboard_ro.sh create mode 100644 scripts/dashboard_ro_role.sql diff --git a/deploy_dashboard_api_staging.sh b/deploy_dashboard_api_staging.sh index c411aed..95e6f04 100755 --- a/deploy_dashboard_api_staging.sh +++ b/deploy_dashboard_api_staging.sh @@ -7,8 +7,9 @@ # # 1. Container name dashboard_api_staging (prod: dashboard_api) # 2. Port / Traefik 8891 + Host(fleetapi.fivetitude.com) (prod: 8890 + rahamafresh) -# 3. DB role READ-ONLY grafana_ro DATABASE_URL, derived on-host from the -# webhook env (prod: the app's read/write DATABASE_URL). +# 3. DB role READ-ONLY dashboard_ro DATABASE_URL — dedicated least-privilege +# role (scripts/bootstrap_dashboard_ro.sh); password read from +# ~/.dashboard_ro.pw (prod: the app's read/write DATABASE_URL). # 4. Refresher OFF VTRIPS_REFRESH_INTERVAL_S=0 — prod owns the v_trips refresh; # a read-only instance must never attempt REFRESH. # @@ -48,13 +49,14 @@ test -f "$MOUNT_DIR/dashboard_api_rev.py" \ || { echo "ERROR: dashboard_api_rev.py missing in $MOUNT_DIR; scp it to ~/dashboard_api_staging_rev.py first"; exit 1; } # Derive a READ-ONLY DATABASE_URL on the host (never printed): take the app's -# DATABASE_URL host:port/dbname and swap the credentials for grafana_ro. +# DATABASE_URL host:port/dbname and swap the credentials for dashboard_ro, whose +# password lives in the host-only 0600 file written by bootstrap_dashboard_ro.sh. SRC_DB_URL=$(docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^DATABASE_URL=//p' | head -1) -RO_PW=$(docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^GRAFANA_DB_RO_PASSWORD=//p' | head -1) +RO_PW=$(cat "${DASHBOARD_RO_PW_FILE:-$HOME/.dashboard_ro.pw}" 2>/dev/null || true) [ -n "$SRC_DB_URL" ] || { echo "ERROR: DATABASE_URL not found in $WH env"; exit 1; } -[ -n "$RO_PW" ] || { echo "ERROR: GRAFANA_DB_RO_PASSWORD not found in $WH env"; exit 1; } +[ -n "$RO_PW" ] || { echo "ERROR: ~/.dashboard_ro.pw missing — run bootstrap_dashboard_ro.sh first"; exit 1; } HOSTPART="${SRC_DB_URL#*@}" # host:port/dbname[?params] -RO_DB_URL="postgresql://grafana_ro:${RO_PW}@${HOSTPART}" +RO_DB_URL="postgresql://dashboard_ro:${RO_PW}@${HOSTPART}" # Reuse the webhook env, stripping runtime noise AND anything we override below # (DATABASE_URL -> read-only, CORS -> staging origins, refresher -> disabled). @@ -91,5 +93,5 @@ sleep 5 echo "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}" echo "== CORS origins in effect =="; docker exec "$NAME" printenv DASHBOARD_CORS_ORIGINS echo "== refresher (expect 0 = disabled) =="; docker exec "$NAME" printenv VTRIPS_REFRESH_INTERVAL_S -echo "== DB role (expect grafana_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:@#"' +echo "== DB role (expect dashboard_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:@#"' echo "== internal health =="; docker exec "$NAME" sh -lc "curl -s http://localhost:${PORT}/health" 2>&1 | head diff --git a/scripts/bootstrap_dashboard_ro.sh b/scripts/bootstrap_dashboard_ro.sh new file mode 100755 index 0000000..c94a4be --- /dev/null +++ b/scripts/bootstrap_dashboard_ro.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# bootstrap_dashboard_ro.sh — create/refresh the dashboard_ro read-only role. +# ───────────────────────────────────────────────────────────────────────────── +# Run ON THE HOST. Generates a strong password into ~/.dashboard_ro.pw (0600) on +# first run (reused thereafter), then applies scripts/dashboard_ro_role.sql to the +# prod DB as the postgres superuser. The password is NEVER printed and never +# leaves the host — the staging deploy script reads the same ~/.dashboard_ro.pw. +# +# Deploy: +# scp scripts/dashboard_ro_role.sql scripts/bootstrap_dashboard_ro.sh \ +# kianiadee@twala.rahamafresh.com:~/ +# ssh kianiadee@twala.rahamafresh.com 'bash ~/bootstrap_dashboard_ro.sh' +# +# Idempotent: re-running rotates nothing unless ~/.dashboard_ro.pw is deleted +# first (then it generates + sets a fresh password and you must redeploy the API). +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +PW_FILE="${DASHBOARD_RO_PW_FILE:-$HOME/.dashboard_ro.pw}" +SQL_FILE="${1:-$HOME/dashboard_ro_role.sql}" + +test -f "$SQL_FILE" || { echo "ERROR: role SQL not found at $SQL_FILE (scp scripts/dashboard_ro_role.sql to ~ first)"; exit 1; } + +if [ ! -s "$PW_FILE" ]; then + ( umask 077; openssl rand -hex 24 > "$PW_FILE" ) + chmod 600 "$PW_FILE" + echo "Generated new dashboard_ro password -> $PW_FILE (0600)" +else + echo "Reusing existing dashboard_ro password from $PW_FILE" +fi +PW=$(cat "$PW_FILE") + +DB=$(docker ps --filter name=timescale_db --format "{{.Names}}" | head -1) +[ -n "$DB" ] || { echo "ERROR: timescale_db container not found"; exit 1; } + +echo "Applying dashboard_ro role DDL to $DB as postgres ..." +docker exec -i "$DB" psql -U postgres -d tracksolid_db -v ON_ERROR_STOP=1 -v ro_pw="$PW" < "$SQL_FILE" +echo "dashboard_ro ready (password not printed). Now (re)run deploy_dashboard_api_staging.sh." diff --git a/scripts/dashboard_ro_role.sql b/scripts/dashboard_ro_role.sql new file mode 100644 index 0000000..fc24bc7 --- /dev/null +++ b/scripts/dashboard_ro_role.sql @@ -0,0 +1,45 @@ +-- dashboard_ro_role.sql — dedicated read-only LOGIN role for dashboard_api. +-- +-- Run as the postgres SUPERUSER (CREATE ROLE), NOT via run_migrations.py (which +-- connects as the app role and may lack CREATEROLE). Apply with +-- scripts/bootstrap_dashboard_ro.sh, which supplies the password as the psql +-- variable :ro_pw from a host-only 0600 file — so no secret lives in this repo. +-- +-- Purpose: a least-privilege role that can serve the FULL dashboard_api read +-- surface, so it backs BOTH the staging instance now (stage 1) AND the live prod +-- connection later (stage 2 — migrate fleetapi.rahamafresh.com off the app role). +-- It therefore grants exactly what the API reads: +-- * SELECT on reporting.* and tracksolid.* (tables + views) +-- * SELECT on the reporting.v_trips MATERIALIZED VIEW — matviews are NOT +-- covered by GRANT ... ON ALL TABLES, so it must be named explicitly +-- * EXECUTE on the reporting.fn_* map functions (fn_live_positions, etc.) +-- * DEFAULT PRIVILEGES so future objects created by the migration role are +-- auto-readable ("dynamic" — no re-grant when we add views) +-- Read-only: no INSERT/UPDATE/DELETE and not the matview owner, so dashboard_ro +-- can never write or REFRESH. Idempotent -> safe to re-apply (also rotates pw). + +\set ON_ERROR_STOP on + +DO $role$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN + CREATE ROLE dashboard_ro LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE; + END IF; +END $role$; + +ALTER ROLE dashboard_ro WITH LOGIN PASSWORD :'ro_pw'; + +GRANT CONNECT ON DATABASE tracksolid_db TO dashboard_ro; +GRANT USAGE ON SCHEMA reporting, tracksolid TO dashboard_ro; + +GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO dashboard_ro; -- tables + views +GRANT SELECT ON ALL TABLES IN SCHEMA tracksolid TO dashboard_ro; -- tables + views +GRANT SELECT ON reporting.v_trips TO dashboard_ro; -- MATERIALIZED VIEW (not in ALL TABLES) +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO dashboard_ro; + +-- "dynamic": future objects created by the migration role (tracksolid_owner) +-- are auto-granted. NOTE: matviews are still never covered — a new matview needs +-- its own explicit GRANT SELECT (as above for v_trips). +ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA reporting GRANT SELECT ON TABLES TO dashboard_ro; +ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA tracksolid GRANT SELECT ON TABLES TO dashboard_ro; +ALTER DEFAULT PRIVILEGES FOR ROLE tracksolid_owner IN SCHEMA reporting GRANT EXECUTE ON FUNCTIONS TO dashboard_ro;