Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/railway-db-runtime-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Railway DB runtime secrets

Railway database credentials must be supplied through runtime environment variables, not committed PHP config or deployment scripts.

Dev-server Railway app config:

- `DB_DSN`
- `DB_USERNAME`
- `DB_PASSWORD`
- `WALLET_DB_DSN`
- `WALLET_DB_USERNAME`
- `WALLET_DB_PASSWORD`
- `REDIS_HOSTNAME`
- `REDIS_USERNAME`
- `REDIS_PASSWORD`
- `REDIS_PORT`
- `REDIS_DATABASE`

Railway deployment scripts:

- `MYSQL_HOST`, defaults to `mysql.railway.internal`
- `MYSQL_PORT`, defaults to `3306`
- `MYSQL_USER`, defaults to `root`
- `MYSQL_DATABASE`, defaults to `railway`
- `MYSQL_PASSWORD`, required

Set `MYSQL_PASSWORD` in Railway variables before running the deployment scripts. The scripts pass it to `mysql` through `MYSQL_PWD` for each command instead of embedding the password in the script or command line.
22 changes: 11 additions & 11 deletions environments/dev-server-railway/common/config/main-local.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
'components' => [
'db' => [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=mysql.railway.internal;dbname=railway',
'username' => 'root',
'password' => 'TpijAlObvfdvZxzPgrnMTHMxyekEqTtt',
'dsn' => getenv('DB_DSN') ?: 'mysql:host=mysql.railway.internal;dbname=railway',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: '',
'charset' => 'utf8mb4',
],
'walletDb' => [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=mysql-5abl.railway.internal;dbname=railway',
'username' => 'root',
'password' => 'hOCpxbVoSIbPUnuuBmaQGILPshVyRRuj',
'dsn' => getenv('WALLET_DB_DSN') ?: 'mysql:host=mysql-5abl.railway.internal;dbname=railway',
'username' => getenv('WALLET_DB_USERNAME') ?: 'root',
'password' => getenv('WALLET_DB_PASSWORD') ?: '',
'charset' => 'utf8',
],
//todo: replace with wallet from sandbox
Expand All @@ -39,11 +39,11 @@
],
'redis' => [
'class' => 'yii\redis\Connection',
'hostname' => 'redis.railway.internal',
'username' => 'default',
'password' => 'nySjmLVspFXlYOzKrOFQcRwuUprjyDli',
'port' => 6379,
'database' => 0,
'hostname' => getenv('REDIS_HOSTNAME') ?: 'redis.railway.internal',
'username' => getenv('REDIS_USERNAME') ?: 'default',
'password' => getenv('REDIS_PASSWORD') ?: null,
'port' => (int)(getenv('REDIS_PORT') ?: 6379),
'database' => (int)(getenv('REDIS_DATABASE') ?: 0),
],
'cache' => [
//'class' => 'yii\redis\Cache',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
#!/bin/sh

MYSQL_HOST="mysql.railway.internal"
MYSQL_PORT=3306
MYSQL_USER="root"
MYSQL_PASSWORD="TpijAlObvfdvZxzPgrnMTHMxyekEqTtt"
MYSQL_DATABASE="railway"
MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
MYSQL_USER="${MYSQL_USER:-root}"
MYSQL_DATABASE="${MYSQL_DATABASE:-railway}"
: "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}"

run_mysql() {
MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$@"
}

echo "Waiting for MySQL at $MYSQL_HOST:$MYSQL_PORT..."
for attempt in $(seq 1 60); do
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" >/dev/null 2>&1 && break
run_mysql -e "SELECT 1" >/dev/null 2>&1 && break
echo "Attempt $attempt: retrying in 1s..."
sleep 1
[ "$attempt" -eq 60 ] && echo "MySQL not responding." && exit 1
done

mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e "
run_mysql "$MYSQL_DATABASE" -e "
ALTER TABLE permission_user DROP COLUMN IF EXISTS companies;
"

Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
#!/bin/sh

MYSQL_HOST="mysql.railway.internal"
MYSQL_PORT=3306
MYSQL_USER="root"
MYSQL_PASSWORD="TpijAlObvfdvZxzPgrnMTHMxyekEqTtt"
MYSQL_DATABASE="railway"
MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
MYSQL_USER="${MYSQL_USER:-root}"
MYSQL_DATABASE="${MYSQL_DATABASE:-railway}"
: "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}"

run_mysql() {
MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$@"
}

echo "Waiting for MySQL at $MYSQL_HOST:$MYSQL_PORT..."
for attempt in $(seq 1 60); do
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" >/dev/null 2>&1 && break
run_mysql -e "SELECT 1" >/dev/null 2>&1 && break
echo "Attempt $attempt: retrying in 1s..."
sleep 1
[ "$attempt" -eq 60 ] && echo "MySQL not responding." && exit 1
done

echo "Disabling ONLY_FULL_GROUP_BY..."
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "
run_mysql -e "
SET GLOBAL sql_mode = REPLACE(@@GLOBAL.sql_mode, 'ONLY_FULL_GROUP_BY', '');
SET SESSION sql_mode = REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '');
"

echo "Converting 'candidate' table to utf8mb4 for emoji support..."
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e "
run_mysql "$MYSQL_DATABASE" -e "
ALTER TABLE candidate
MODIFY candidate_intro TEXT
CHARACTER SET utf8mb4
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
#!/bin/sh

MYSQL_HOST="mysql.railway.internal"
MYSQL_PORT=3306
MYSQL_USER="root"
MYSQL_PASSWORD="JImnisvcRDpKLdWpoMECoHHoCbutPhQC"
MYSQL_DATABASE="railway"
MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
MYSQL_USER="${MYSQL_USER:-root}"
MYSQL_DATABASE="${MYSQL_DATABASE:-railway}"
: "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}"

run_mysql() {
MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$@"
}

echo "Waiting for MySQL at $MYSQL_HOST:$MYSQL_PORT..."
for attempt in $(seq 1 60); do
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" >/dev/null 2>&1 && break
run_mysql -e "SELECT 1" >/dev/null 2>&1 && break
echo "Attempt $attempt: retrying in 1s..."
sleep 1
[ "$attempt" -eq 60 ] && echo "MySQL not responding." && exit 1
done

mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e "
run_mysql "$MYSQL_DATABASE" -e "
ALTER TABLE permission_user DROP COLUMN IF EXISTS companies;
"

Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
#!/bin/sh

MYSQL_HOST="mysql.railway.internal"
MYSQL_PORT=3306
MYSQL_USER="root"
MYSQL_PASSWORD="JImnisvcRDpKLdWpoMECoHHoCbutPhQC"
MYSQL_DATABASE="railway"
MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
MYSQL_USER="${MYSQL_USER:-root}"
MYSQL_DATABASE="${MYSQL_DATABASE:-railway}"
: "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}"

run_mysql() {
MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$@"
}

echo "Waiting for MySQL at $MYSQL_HOST:$MYSQL_PORT..."
for attempt in $(seq 1 60); do
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" >/dev/null 2>&1 && break
run_mysql -e "SELECT 1" >/dev/null 2>&1 && break
echo "Attempt $attempt: retrying in 1s..."
sleep 1
[ "$attempt" -eq 60 ] && echo "MySQL not responding." && exit 1
done

echo "Disabling ONLY_FULL_GROUP_BY..."
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "
run_mysql -e "
SET GLOBAL sql_mode = REPLACE(@@GLOBAL.sql_mode, 'ONLY_FULL_GROUP_BY', '');
SET SESSION sql_mode = REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '');
"


echo "Converting 'candidate' table to utf8mb4 for emoji support..."
mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e "
run_mysql "$MYSQL_DATABASE" -e "
ALTER TABLE candidate
MODIFY candidate_intro TEXT
CHARACTER SET utf8mb4
Expand Down
148 changes: 148 additions & 0 deletions tests/check-railway-db-runtime-secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Validate that Railway database credentials stay runtime-configured."""


import os
import re
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]

DEV_CONFIG = ROOT / "environments/dev-server-railway/common/config/main-local.php"
DEPLOYMENT_SCRIPTS = [
ROOT / "environments/dev-server-railway/deployments/july_2025/3_july_2025_deployment.sh",
ROOT / "environments/dev-server-railway/deployments/july_2025/29_july_2025_deployment.sh",
ROOT / "environments/prod-railway/deployments/july_2025/3_july_2025_deployment.sh",
ROOT / "environments/prod-railway/deployments/july_2025/29_july_2025_deployment.sh",
]
DOC = ROOT / "docs/railway-db-runtime-secrets.md"

DEV_CONFIG_ENV_VARS = [
"DB_DSN",
"DB_USERNAME",
"DB_PASSWORD",
"WALLET_DB_DSN",
"WALLET_DB_USERNAME",
"WALLET_DB_PASSWORD",
"REDIS_HOSTNAME",
"REDIS_USERNAME",
"REDIS_PASSWORD",
"REDIS_PORT",
"REDIS_DATABASE",
]

SCRIPT_TOKENS = [
'MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}"',
'MYSQL_PORT="${MYSQL_PORT:-3306}"',
'MYSQL_USER="${MYSQL_USER:-root}"',
'MYSQL_DATABASE="${MYSQL_DATABASE:-railway}"',
': "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}"',
'MYSQL_PWD="$MYSQL_PASSWORD" mysql',
]

COMPONENT_PASSWORD_ENV = {
"db": "DB_PASSWORD",
"walletDb": "WALLET_DB_PASSWORD",
"redis": "REDIS_PASSWORD",
}


def load_forbidden_secret_fragments():
"""Load optional secret fragments without committing them to this test."""
fragments = []
fragments.extend(os.environ.get("FORBIDDEN_SECRET_FRAGMENTS", "").splitlines())

fragment_file = os.environ.get("FORBIDDEN_SECRET_FRAGMENTS_FILE")
if fragment_file:
fragment_path = Path(fragment_file)
require(
fragment_path.is_file(),
f"Forbidden secret fragment file is not readable: {fragment_file}",
)
try:
fragments.extend(fragment_path.read_text(encoding="utf-8").splitlines())
except OSError as error:
raise SystemExit(f"Could not read forbidden secret fragment file {fragment_file}: {error}") from error

return [fragment.strip() for fragment in fragments if fragment.strip()]


def read(path):
"""Read a repository file as UTF-8 text."""
return path.read_text(encoding="utf-8")


def require(condition, message):
"""Fail the check with a clear message when a condition is false."""
if not condition:
raise SystemExit(message)


def component_block(config, name):
"""Return the top-level component block from the Yii config."""
marker = f" '{name}' => ["
start = config.find(marker)
require(start != -1, f"Dev Railway config must define the {name} component.")
end = config.find("\n ],", start)
require(end != -1, f"Dev Railway config must close the {name} component.")
return config[start:end]


def uses_getenv(text, env_var):
"""Return whether text reads an environment variable via getenv."""
return re.search(rf"getenv\(\s*['\"]{re.escape(env_var)}['\"]\s*\)", text) is not None


def password_uses_getenv(text, env_var):
"""Return whether a PHP password key reads the expected environment variable."""
pattern = rf"['\"]password['\"]\s*=>\s*getenv\(\s*['\"]{re.escape(env_var)}['\"]\s*\)"
return re.search(pattern, text) is not None


def password_has_inline_literal(text):
"""Return whether a PHP password key is assigned a quoted literal."""
return re.search(r"['\"]password['\"]\s*=>\s*['\"]", text) is not None


def main():
"""Run the Railway runtime secret regression checks."""
config = read(DEV_CONFIG)
doc = read(DOC)
script_contents = [read(path) for path in DEPLOYMENT_SCRIPTS]
forbidden_fragments = load_forbidden_secret_fragments()
checked_files = [
(DEV_CONFIG, config),
(DOC, doc),
*zip(DEPLOYMENT_SCRIPTS, script_contents),
]

for name in DEV_CONFIG_ENV_VARS:
require(uses_getenv(config, name), f"Dev Railway config must use getenv('{name}').")
require(f"`{name}`" in doc, f"Docs must mention `{name}`.")

for component, env_var in COMPONENT_PASSWORD_ENV.items():
block = component_block(config, component)
require(
password_uses_getenv(block, env_var),
f"{component} password must come from getenv('{env_var}').",
)
require(not password_has_inline_literal(block), f"{component} password must not be an inline literal.")

for path, content in zip(DEPLOYMENT_SCRIPTS, script_contents):
for token in SCRIPT_TOKENS:
require(token in content, f"{path} must contain {token!r}.")
require('-p"$MYSQL_PASSWORD"' not in content, f"{path} must not pass MYSQL_PASSWORD on the command line.")
require('MYSQL_PASSWORD="' not in content, f"{path} must not assign a committed MYSQL_PASSWORD.")

for path, content in checked_files:
match_count = sum(1 for fragment in forbidden_fragments if fragment in content)
require(
match_count == 0,
f"{path} must not contain {match_count} runtime-provided forbidden secret fragment(s).",
)

print("Railway DB runtime secret check passed.")


if __name__ == "__main__":
main()