Skip to content

Commit 5edcc5d

Browse files
SashkoMarchukclaude
andcommitted
infra(cpb): add dedicated role provisioning with master password
Replace temporal-based DB setup with a two-script architecture: - create-role.sh: one-time provisioning using RDS master password to create cpb_app role and cpb_bot database - setup-db.sh: repeatable schema setup connecting as cpb_app Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6a7e67f commit 5edcc5d

3 files changed

Lines changed: 235 additions & 66 deletions

File tree

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ TEMPORAL_PORT=7233
2828
TEMPORAL_UI_PORT=8080
2929

3030
# CPB (Connecting People Bot) — Development
31-
## PostgreSQL provisioning (used by init-db.sh to create the database and role)
31+
## PostgreSQL provisioning (used by create-role.sh and setup-db.sh)
3232
POSTGRES_DB_CPB=cpb_bot
3333
POSTGRES_USER_CPB=cpb_app
3434
POSTGRES_PASSWORD_CPB=cpb_password
35+
## RDS master password (one-time use by create-role.sh, remove after provisioning)
36+
# POSTGRES_PASSWORD_MASTER=
3537
## CPB application connection (passed to the app at runtime)
3638
CPB_POSTGRES_HOST=postgresql
3739
CPB_POSTGRES_DB=cpb_bot

scripts/cpb/create-role.sh

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/bin/bash
2+
set -eo pipefail
3+
4+
# =============================================================================
5+
# CPB Role & Database Provisioning (one-time setup)
6+
# =============================================================================
7+
# Connects to an external PostgreSQL (RDS) server using the master password to
8+
# create a dedicated cpb_app role and cpb_bot database. This replaces the
9+
# interim approach of using the temporal user as database owner.
10+
#
11+
# Designed as a one-time provisioning tool:
12+
# 1. Run this script with the RDS master password
13+
# 2. Run setup-db.sh to create the schema
14+
# 3. Remove the master password from your environment
15+
#
16+
# Idempotency:
17+
# - Safe to re-run. Existing role gets its password updated; existing
18+
# database is left untouched; ownership is transferred only if needed.
19+
#
20+
# Usage:
21+
# CPB_POSTGRES_HOST="<host>" \
22+
# POSTGRES_PASSWORD_MASTER="<master_pw>" \
23+
# POSTGRES_PASSWORD_CPB="<cpb_pw>" \
24+
# ./scripts/cpb/create-role.sh
25+
#
26+
# Required env vars:
27+
# CPB_POSTGRES_HOST — PostgreSQL server hostname or IP
28+
# POSTGRES_PASSWORD_MASTER — RDS master (postgres) password
29+
# POSTGRES_PASSWORD_CPB — Password to assign to the cpb_app role
30+
#
31+
# Optional env vars:
32+
# CPB_POSTGRES_PORT — PostgreSQL port (default: 5432)
33+
# POSTGRES_USER_MASTER — Master user name (default: postgres)
34+
# POSTGRES_USER_CPB — CPB role name (default: cpb_app)
35+
# POSTGRES_DB_CPB — Database name (default: cpb_bot)
36+
# =============================================================================
37+
38+
# --- Required env vars (fail immediately if missing) --------------------------
39+
PGHOST="${CPB_POSTGRES_HOST:?CPB_POSTGRES_HOST is required}"
40+
MASTER_PASS="${POSTGRES_PASSWORD_MASTER:?POSTGRES_PASSWORD_MASTER is required}"
41+
CPB_PASS="${POSTGRES_PASSWORD_CPB:?POSTGRES_PASSWORD_CPB is required}"
42+
43+
# --- Optional env vars with defaults -----------------------------------------
44+
PGPORT="${CPB_POSTGRES_PORT:-5432}"
45+
MASTER_USER="${POSTGRES_USER_MASTER:-postgres}"
46+
CPB_USER="${POSTGRES_USER_CPB:-cpb_app}"
47+
CPB_DB="${POSTGRES_DB_CPB:-cpb_bot}"
48+
49+
# --- Validate identifiers (prevent SQL injection via crafted names) -----------
50+
validate_pg_identifier() {
51+
local value="$1" name="$2"
52+
if [[ -z "$value" ]]; then
53+
echo "ERROR: ${name} cannot be empty" >&2; exit 1
54+
fi
55+
if [[ ${#value} -gt 63 ]]; then
56+
echo "ERROR: ${name} exceeds PostgreSQL's 63-char identifier limit" >&2; exit 1
57+
fi
58+
if [[ ! "$value" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
59+
echo "ERROR: ${name} contains invalid characters (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)" >&2; exit 1
60+
fi
61+
return 0
62+
}
63+
validate_pg_identifier "$MASTER_USER" "POSTGRES_USER_MASTER"
64+
validate_pg_identifier "$CPB_USER" "POSTGRES_USER_CPB"
65+
validate_pg_identifier "$CPB_DB" "POSTGRES_DB_CPB"
66+
67+
# --- Escape single quotes in passwords for safe SQL embedding -----------------
68+
# Also reject passwords containing $$ which would break PL/pgSQL dollar-quoting
69+
if [[ "$CPB_PASS" == *'$$'* ]]; then
70+
echo "ERROR: POSTGRES_PASSWORD_CPB must not contain '\$\$' (breaks PL/pgSQL quoting)" >&2
71+
exit 1
72+
fi
73+
ESCAPED_CPB_PASS="${CPB_PASS//\'/''}"
74+
75+
echo "=== CPB Role & Database Provisioning ==="
76+
echo " Host: ${PGHOST}:${PGPORT}"
77+
echo " Master: ${MASTER_USER}"
78+
echo " Role: ${CPB_USER}"
79+
echo " Database: ${CPB_DB}"
80+
echo ""
81+
82+
# --- Step 1: Create role (idempotent) ----------------------------------------
83+
# If the role already exists, update its password to converge to desired state.
84+
echo "Step 1: Ensuring role '${CPB_USER}' exists..."
85+
PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \
86+
-h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d postgres <<-EOSQL
87+
DO \$\$
88+
BEGIN
89+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${CPB_USER}') THEN
90+
CREATE ROLE "${CPB_USER}" LOGIN ENCRYPTED PASSWORD '${ESCAPED_CPB_PASS}';
91+
RAISE NOTICE 'Created role ${CPB_USER}';
92+
ELSE
93+
ALTER ROLE "${CPB_USER}" WITH LOGIN ENCRYPTED PASSWORD '${ESCAPED_CPB_PASS}';
94+
RAISE NOTICE 'Role ${CPB_USER} already exists — password updated';
95+
END IF;
96+
END
97+
\$\$;
98+
EOSQL
99+
echo " Role '${CPB_USER}': OK"
100+
101+
# --- Step 2: Create database (idempotent) ------------------------------------
102+
echo "Step 2: Ensuring database '${CPB_DB}' exists..."
103+
PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \
104+
-h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d postgres <<-EOSQL
105+
SELECT 'CREATE DATABASE "${CPB_DB}" OWNER "${CPB_USER}"'
106+
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${CPB_DB}')\gexec
107+
EOSQL
108+
echo " Database '${CPB_DB}': OK"
109+
110+
# --- Step 3: Transfer ownership if needed (idempotent) -----------------------
111+
echo "Step 3: Ensuring '${CPB_USER}' owns '${CPB_DB}'..."
112+
PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \
113+
-h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d postgres <<-EOSQL
114+
DO \$\$
115+
DECLARE
116+
current_owner TEXT;
117+
BEGIN
118+
SELECT pg_catalog.pg_get_userbyid(d.datdba) INTO current_owner
119+
FROM pg_database d WHERE d.datname = '${CPB_DB}';
120+
121+
IF current_owner IS NULL THEN
122+
RAISE EXCEPTION 'Database ${CPB_DB} not found — this should not happen';
123+
ELSIF current_owner = '${CPB_USER}' THEN
124+
RAISE NOTICE 'Database ${CPB_DB} already owned by ${CPB_USER}';
125+
ELSE
126+
EXECUTE 'ALTER DATABASE "${CPB_DB}" OWNER TO "${CPB_USER}"';
127+
RAISE NOTICE 'Transferred ownership from % to ${CPB_USER}', current_owner;
128+
END IF;
129+
END
130+
\$\$;
131+
EOSQL
132+
echo " Ownership: OK"
133+
134+
# --- Step 4: Grant database-level privileges (idempotent) --------------------
135+
echo "Step 4: Granting database privileges..."
136+
PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \
137+
-h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d postgres <<-EOSQL
138+
GRANT ALL PRIVILEGES ON DATABASE "${CPB_DB}" TO "${CPB_USER}";
139+
EOSQL
140+
echo " Database GRANT: OK"
141+
142+
# --- Step 5: Grant schema-level privileges (idempotent) ----------------------
143+
# As superuser we can always grant on public schema, regardless of PG version.
144+
echo "Step 5: Granting schema privileges on '${CPB_DB}'..."
145+
PGPASSWORD="${MASTER_PASS}" psql -v ON_ERROR_STOP=1 \
146+
-h "$PGHOST" -p "$PGPORT" -U "$MASTER_USER" -d "$CPB_DB" <<-EOSQL
147+
GRANT ALL ON SCHEMA public TO "${CPB_USER}";
148+
EOSQL
149+
echo " Schema GRANT: OK"
150+
151+
echo ""
152+
echo "=== Provisioning complete ==="
153+
echo " Role: ${CPB_USER} (LOGIN)"
154+
echo " Database: ${CPB_DB} (owned by ${CPB_USER})"
155+
echo ""
156+
echo "Next steps:"
157+
echo " 1. Run setup-db.sh to create the schema tables"
158+
echo " 2. Remove POSTGRES_PASSWORD_MASTER from your environment"

scripts/cpb/setup-db.sh

Lines changed: 74 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,47 @@
22
set -eo pipefail
33

44
# =============================================================================
5-
# CPB Production Database Setup
5+
# CPB Schema Setup
66
# =============================================================================
7-
# Creates the cpb_bot database on an external PostgreSQL (RDS) server using the
8-
# temporal user (which has CREATEDB privilege). Grants access to the n8n user
9-
# so the CPB application (running inside n8n) can manage its tables.
7+
# Connects to the cpb_bot database as the cpb_app role and applies the
8+
# init-schema.sql file to create tables, indexes, and triggers.
109
#
11-
# Ownership model:
12-
# - temporal creates and owns the database (only user with CREATEDB)
13-
# - n8n gets ALL PRIVILEGES on the database to create/manage tables
14-
# - Later, when DevOps provides the master password, ownership can be
15-
# migrated to a dedicated cpb_app user
10+
# Prerequisites:
11+
# - Role cpb_app and database cpb_bot must already exist.
12+
# Run create-role.sh first if they don't.
1613
#
17-
# PostgreSQL version notes:
18-
# - PG14: public schema grants CREATE to PUBLIC by default — n8n can create
19-
# tables without explicit schema grants
20-
# - PG15+: CREATE on public schema is revoked by default — the script
21-
# attempts to grant schema privileges and warns if it cannot (the DB owner
22-
# can do this on PG15+ since public schema is owned by pg_database_owner)
14+
# Idempotency:
15+
# - Safe to re-run. All DDL uses IF NOT EXISTS / CREATE OR REPLACE.
2316
#
2417
# Usage:
25-
# CPB_POSTGRES_HOST="<host>" POSTGRES_PASSWORD_TEMPORAL="<pw>" \
26-
# POSTGRES_USER_N8N="n8n" ./scripts/cpb/setup-db.sh
18+
# CPB_POSTGRES_HOST="<host>" POSTGRES_PASSWORD_CPB="<pw>" \
19+
# ./scripts/cpb/setup-db.sh
2720
#
2821
# Required env vars:
29-
# CPB_POSTGRES_HOST — PostgreSQL server hostname or IP
30-
# POSTGRES_PASSWORD_TEMPORAL — Password for the temporal user
22+
# CPB_POSTGRES_HOST — PostgreSQL server hostname or IP
23+
# POSTGRES_PASSWORD_CPB — Password for the cpb_app role
3124
#
3225
# Optional env vars:
33-
# CPB_POSTGRES_PORT — PostgreSQL port (default: 5432)
34-
# POSTGRES_USER_TEMPORAL — Temporal user name (default: temporal)
35-
# POSTGRES_USER_N8N — n8n user to grant access to (default: n8n)
36-
# POSTGRES_DB_CPB — Database name (default: cpb_bot)
26+
# CPB_POSTGRES_PORT — PostgreSQL port (default: 5432)
27+
# POSTGRES_USER_CPB — CPB role name (default: cpb_app)
28+
# POSTGRES_DB_CPB — Database name (default: cpb_bot)
3729
# =============================================================================
3830

31+
# --- Required env vars (fail immediately if missing) --------------------------
3932
PGHOST="${CPB_POSTGRES_HOST:?CPB_POSTGRES_HOST is required}"
33+
CPB_PASS="${POSTGRES_PASSWORD_CPB:?POSTGRES_PASSWORD_CPB is required}"
34+
35+
# --- Optional env vars with defaults -----------------------------------------
4036
PGPORT="${CPB_POSTGRES_PORT:-5432}"
41-
TEMPORAL_USER="${POSTGRES_USER_TEMPORAL:-temporal}"
42-
N8N_USER="${POSTGRES_USER_N8N:-n8n}"
37+
CPB_USER="${POSTGRES_USER_CPB:-cpb_app}"
4338
CPB_DB="${POSTGRES_DB_CPB:-cpb_bot}"
4439

45-
TEMPORAL_PASS="${POSTGRES_PASSWORD_TEMPORAL:?POSTGRES_PASSWORD_TEMPORAL is required}"
40+
# --- Resolve script directory for locating SQL files --------------------------
41+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
42+
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
43+
SCHEMA_FILE="${REPO_ROOT}/sql/cpb/init-schema.sql"
4644

47-
# Validate PostgreSQL identifiers (prevent SQL injection via crafted names)
45+
# --- Validate identifiers (prevent SQL injection via crafted names) -----------
4846
validate_pg_identifier() {
4947
local value="$1" name="$2"
5048
if [[ -z "$value" ]]; then
@@ -58,52 +56,63 @@ validate_pg_identifier() {
5856
fi
5957
return 0
6058
}
61-
validate_pg_identifier "$TEMPORAL_USER" "POSTGRES_USER_TEMPORAL"
62-
validate_pg_identifier "$N8N_USER" "POSTGRES_USER_N8N"
59+
validate_pg_identifier "$CPB_USER" "POSTGRES_USER_CPB"
6360
validate_pg_identifier "$CPB_DB" "POSTGRES_DB_CPB"
6461

65-
echo "Setting up CPB database on ${PGHOST}:${PGPORT}..."
66-
echo " Database: ${CPB_DB}"
67-
echo " Owner: ${TEMPORAL_USER} (interim — migrate to dedicated user later)"
68-
echo " Grantee: ${N8N_USER}"
62+
# --- Verify schema file exists ------------------------------------------------
63+
if [[ ! -f "$SCHEMA_FILE" ]]; then
64+
echo "ERROR: Schema file not found: ${SCHEMA_FILE}" >&2
65+
echo " Expected at: sql/cpb/init-schema.sql (relative to repo root)" >&2
66+
exit 1
67+
fi
6968

70-
# --- Step 1: Create database and grant database-level privileges -----------
71-
# Connect to the 'postgres' maintenance database to run DDL.
72-
# CREATE DATABASE cannot run inside a transaction, so we use \gexec.
73-
PGPASSWORD="${TEMPORAL_PASS}" psql -v ON_ERROR_STOP=1 \
74-
-h "$PGHOST" -p "$PGPORT" -U "$TEMPORAL_USER" -d postgres <<-EOSQL
75-
-- Create database if it doesn't exist (temporal becomes owner)
76-
SELECT 'CREATE DATABASE "${CPB_DB}"'
77-
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${CPB_DB}')\gexec
69+
echo "=== CPB Schema Setup ==="
70+
echo " Host: ${PGHOST}:${PGPORT}"
71+
echo " User: ${CPB_USER}"
72+
echo " Database: ${CPB_DB}"
73+
echo " Schema: ${SCHEMA_FILE}"
74+
echo ""
7875

79-
-- Grant all database-level privileges to n8n (idempotent)
80-
GRANT ALL PRIVILEGES ON DATABASE "${CPB_DB}" TO "${N8N_USER}";
81-
EOSQL
76+
# --- Step 1: Verify connectivity ---------------------------------------------
77+
echo "Step 1: Verifying database connection..."
78+
if ! PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 \
79+
-h "$PGHOST" -p "$PGPORT" -U "$CPB_USER" -d "$CPB_DB" \
80+
-c "SELECT 1;" > /dev/null 2>&1; then
81+
echo "ERROR: Cannot connect to ${CPB_DB} as ${CPB_USER}" >&2
82+
echo " Have you run create-role.sh first?" >&2
83+
exit 1
84+
fi
85+
echo " Connection: OK"
86+
87+
# --- Step 2: Apply schema ----------------------------------------------------
88+
echo "Step 2: Applying schema from init-schema.sql..."
89+
PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 \
90+
-h "$PGHOST" -p "$PGPORT" -U "$CPB_USER" -d "$CPB_DB" \
91+
-f "$SCHEMA_FILE"
92+
echo " Schema applied: OK"
8293

83-
# --- Step 2: Grant schema-level privileges ---------------------------------
84-
# On PG14, this is unnecessary (PUBLIC has CREATE on public schema by default)
85-
# but we attempt it for forward-compatibility with PG15+ where it IS required.
86-
# temporal cannot grant on public schema in PG14 (owned by postgres), so we
87-
# handle the error gracefully.
88-
if SCHEMA_GRANT_ERR=$(PGPASSWORD="${TEMPORAL_PASS}" psql -v ON_ERROR_STOP=1 \
89-
-h "$PGHOST" -p "$PGPORT" -U "$TEMPORAL_USER" -d "$CPB_DB" \
90-
-c "GRANT ALL ON SCHEMA public TO \"${N8N_USER}\";" 2>&1); then
91-
echo " Schema grant on public: OK"
94+
# --- Step 3: Verify tables were created ---------------------------------------
95+
echo "Step 3: Verifying tables..."
96+
TABLE_COUNT=$(PGPASSWORD="${CPB_PASS}" psql -v ON_ERROR_STOP=1 -t -A \
97+
-h "$PGHOST" -p "$PGPORT" -U "$CPB_USER" -d "$CPB_DB" \
98+
-c "SELECT count(*) FROM information_schema.tables
99+
WHERE table_schema = 'public'
100+
AND table_type = 'BASE TABLE'
101+
AND table_name IN (
102+
'cycles', 'opt_in_responses', 'pairings',
103+
'pair_history', 'interactions', 'admin_reports'
104+
);")
105+
106+
if [[ "$TABLE_COUNT" -eq 6 ]]; then
107+
echo " All 6 tables verified: OK"
92108
else
93-
if echo "$SCHEMA_GRANT_ERR" | grep -qi "permission denied\|must be owner"; then
94-
echo " Schema grant on public: skipped (not needed on PG14 — PUBLIC has CREATE by default)"
95-
echo " NOTE: After upgrading to PG15+, re-run this script or grant manually:"
96-
echo " GRANT ALL ON SCHEMA public TO \"${N8N_USER}\";"
97-
else
98-
echo "ERROR: Schema grant failed unexpectedly:" >&2
99-
echo " $SCHEMA_GRANT_ERR" >&2
100-
exit 1
101-
fi
109+
echo "WARNING: Expected 6 tables, found ${TABLE_COUNT}" >&2
110+
echo " Run with -v for psql debug output to investigate" >&2
102111
fi
103112

104113
echo ""
105-
echo "CPB database setup complete."
114+
echo "=== Schema setup complete ==="
106115
echo " Database: ${CPB_DB}"
107-
echo " Owner: ${TEMPORAL_USER}"
108-
echo " Access: ${N8N_USER} (all privileges)"
116+
echo " User: ${CPB_USER}"
117+
echo " Tables: ${TABLE_COUNT}/6"
109118
echo " Host: ${PGHOST}:${PGPORT}"

0 commit comments

Comments
 (0)