feat(db): dedicated read-only dashboard_ro role + repoint staging
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 <noreply@anthropic.com>
This commit is contained in:
parent
8ddbd7b745
commit
cbbe3dab87
3 changed files with 92 additions and 7 deletions
|
|
@ -7,8 +7,9 @@
|
||||||
#
|
#
|
||||||
# 1. Container name dashboard_api_staging (prod: dashboard_api)
|
# 1. Container name dashboard_api_staging (prod: dashboard_api)
|
||||||
# 2. Port / Traefik 8891 + Host(fleetapi.fivetitude.com) (prod: 8890 + rahamafresh)
|
# 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
|
# 3. DB role READ-ONLY dashboard_ro DATABASE_URL — dedicated least-privilege
|
||||||
# webhook env (prod: the app's read/write DATABASE_URL).
|
# 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;
|
# 4. Refresher OFF VTRIPS_REFRESH_INTERVAL_S=0 — prod owns the v_trips refresh;
|
||||||
# a read-only instance must never attempt 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; }
|
|| { 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
|
# 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)
|
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 "$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]
|
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
|
# Reuse the webhook env, stripping runtime noise AND anything we override below
|
||||||
# (DATABASE_URL -> read-only, CORS -> staging origins, refresher -> disabled).
|
# (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 "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}"
|
||||||
echo "== CORS origins in effect =="; docker exec "$NAME" printenv DASHBOARD_CORS_ORIGINS
|
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 "== 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:<pw>@#"'
|
echo "== DB role (expect dashboard_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:<pw>@#"'
|
||||||
echo "== internal health =="; docker exec "$NAME" sh -lc "curl -s http://localhost:${PORT}/health" 2>&1 | head
|
echo "== internal health =="; docker exec "$NAME" sh -lc "curl -s http://localhost:${PORT}/health" 2>&1 | head
|
||||||
|
|
|
||||||
38
scripts/bootstrap_dashboard_ro.sh
Executable file
38
scripts/bootstrap_dashboard_ro.sh
Executable file
|
|
@ -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."
|
||||||
45
scripts/dashboard_ro_role.sql
Normal file
45
scripts/dashboard_ro_role.sql
Normal file
|
|
@ -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;
|
||||||
Loading…
Reference in a new issue