b2f75495de
Vorher: prestart.sh fuehrte bei JEDEM Container-Start GRANT DELETE ON ALL TABLES fuer crm_api/crm_auth/crm_worker aus — und hob damit Migration 0100 auf, die DELETE auf 12 sensiblen Tabellen (audit_log, api_tokens, password_reset_tokens, tenants, ...) gezielt entzogen hatte. Der Blanket-Grant war ein BUG-030-Workaround (User-DELETE 500), der den Schutz seit jedem Start zerstoerte. Fix: - Migration 0145 (0145_delete_grants_converged): deterministischer Sollzustand — REVOKE DELETE auf geschuetzten Tabellen von beiden Runtime-Rollen (audit_log, api_tokens, password_reset_tokens, plugin_allowlist, plugin_migrations, tenants, tenant_plugin_activation); GRANT DELETE auf legitime Runtime-Loeschungen (users, user_tenants, sessions, plugins, notification_types) NUR fuer crm_api; crm_worker erhaelt kein DELETE auf geschuetzten Tabellen. - prestart.sh: Blanket-GRANT-Block entfernt, durch dokumentierenden Verweis auf 0145 ersetzt. - audit.py Retention-Route: Delete laeuft ueber Migrations-Session-Factory (Table-Owner) statt Request-DB — Runtime-Rollen koennen Auditdaten schreiben aber NIEMALS loeschen (Astra-Abnahme). Gleiches Muster wie Plugin-Uninstall. Abnahme (Astra): API und Worker koennen Auditdaten schreiben, aber nicht loeschen — erfuellt (audit_log DELETE von crm_api/crm_worker entzogen, Retention als dokumentierte Wartungsoperation ueber Owner-Session). Verifikation: Migration-Syntax OK, ruff clean, alembic heads = genau 0145, prestart bash -n OK, test_audit_architecture_fixes + test_user_service 30/30 (Logout-Session-Delete, User-DELETE, Audit-Pfade alle intakt). Bekannte Grenze (ehrlich): Kuenftige Plugin-Tabellen brauchen ihre DELETE-Rechte in der jeweiligen Migration statt im Boot-Skript — sync_plugin_schema.py vergibt KEINE GRANTs (verifiziert), deshalb ist das Default-Privilege-Problem in S2 (F18 Schema-Verantwortung) adressiert.
160 lines
6.4 KiB
Bash
160 lines
6.4 KiB
Bash
#!/bin/sh
|
|
# =============================================================================
|
|
# prestart.sh — Container entrypoint for CRM API container
|
|
#
|
|
# Responsibilities:
|
|
# 1. Run Alembic DB migrations (alembic upgrade head) using the migration user.
|
|
# 2. Set passwords for all application DB roles (crm_api, crm_auth, crm_worker, crm_migration).
|
|
# 3. Add owner_id to plugin tables that were created after core migration 0054.
|
|
# 4. Start uvicorn as PID 1 (so signals like SIGTERM are forwarded correctly).
|
|
#
|
|
# Notes:
|
|
# - `set -e` ensures the container crashes loudly if migrations fail.
|
|
# - The ARQ worker runs in a separate container (see worker.sh / docker-compose).
|
|
# - Migrations use MIGRATION_DATABASE_URL (crm_user for bootstrap, then crm_migration).
|
|
# - The app uses DATABASE_URL (crm_api, NOSUPERUSER, NOBYPASSRLS).
|
|
# =============================================================================
|
|
|
|
set -e
|
|
|
|
# Use MIGRATION_DATABASE_URL for alembic (falls back to DATABASE_URL for backwards compat)
|
|
export ALEMBIC_DATABASE_URL="${MIGRATION_DATABASE_URL:-$DATABASE_URL}"
|
|
|
|
# Configure alembic to use the migration database URL
|
|
export ALEMBIC_DATABASE_URL
|
|
|
|
echo "[prestart] $(date -u +%Y-%m-%dT%H:%M:%SZ) - Running alembic upgrade head (owner user)..."
|
|
# Temporarily override DATABASE_URL for alembic
|
|
DATABASE_URL="$ALEMBIC_DATABASE_URL" alembic upgrade head
|
|
echo "[prestart] DB migrations completed successfully."
|
|
|
|
# Set passwords for all application DB roles (crm_api, crm_auth, crm_worker, crm_migration)
|
|
# Migration 0070 creates these roles without passwords; we set them here so the
|
|
# API/Worker/Auth connections can authenticate.
|
|
echo "[prestart] Setting DB role passwords..."
|
|
cat > /tmp/set_role_passwords.py << 'PYEOF'
|
|
import asyncio
|
|
import os
|
|
import re
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlalchemy import text
|
|
|
|
async def set_passwords():
|
|
db_url = os.environ.get('MIGRATION_DATABASE_URL', os.environ.get('DATABASE_URL', ''))
|
|
if not db_url:
|
|
print('[prestart] WARNING: No DB URL for password setup')
|
|
return
|
|
match = re.search(r'://([^:]+):([^@]+)@', db_url)
|
|
if not match:
|
|
print('[prestart] WARNING: Could not extract password from DB URL')
|
|
return
|
|
pwd = match.group(2)
|
|
engine = create_async_engine(db_url)
|
|
roles = ['crm_api', 'crm_auth', 'crm_worker', 'crm_migration']
|
|
try:
|
|
async with engine.begin() as conn:
|
|
for role in roles:
|
|
try:
|
|
await conn.execute(text(f"ALTER ROLE {role} WITH LOGIN PASSWORD '{pwd}'"))
|
|
print(f'[prestart] Password set for {role}')
|
|
except Exception as e:
|
|
print(f'[prestart] WARNING: Could not set password for {role}: {e}')
|
|
print('[prestart] DB role passwords set.')
|
|
except Exception as e:
|
|
print(f'[prestart] WARNING: Could not set DB role passwords: {e}')
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
asyncio.run(set_passwords())
|
|
PYEOF
|
|
python3 /tmp/set_role_passwords.py
|
|
rm -f /tmp/set_role_passwords.py
|
|
|
|
# F20 (Astra P1): the blanket "GRANT DELETE ON ALL TABLES" block that used
|
|
# to run here was removed — it silently undid migration 0100's targeted
|
|
# DELETE revocations on EVERY container start (audit_log, api_tokens,
|
|
# password_reset_tokens, tenants, ...).
|
|
# The documented target state now lives in migration 0145
|
|
# (0145_delete_grants_converged.py): runtime DELETEs only where the app
|
|
# legitimately deletes rows (users, user_tenants, sessions, plugins,
|
|
# notification_types) and DELETE revoked from crm_api/crm_worker on all
|
|
# protected tables. Audit-log retention runs via the migration session
|
|
# factory (app/routes/audit.py), plugin uninstall via the migration
|
|
# factory as before.
|
|
|
|
# Set crm_runtime password if RUNTIME_DB_PASSWORD is set (legacy support)
|
|
if [ -n "$RUNTIME_DB_PASSWORD" ]; then
|
|
echo "[prestart] Setting crm_runtime password..."
|
|
python3 -c "
|
|
import asyncio
|
|
import os
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlalchemy import text
|
|
|
|
async def set_password():
|
|
db_url = os.environ.get('MIGRATION_DATABASE_URL', os.environ.get('DATABASE_URL', ''))
|
|
if not db_url:
|
|
print('[prestart] WARNING: No DB URL for password setup')
|
|
return
|
|
engine = create_async_engine(db_url)
|
|
pwd = os.environ.get('RUNTIME_DB_PASSWORD', '')
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.execute(text(
|
|
"ALTER ROLE crm_runtime WITH LOGIN PASSWORD :pwd NOSUPERUSER NOBYPASSRLS"
|
|
), {"pwd": pwd})
|
|
print('[prestart] crm_runtime password set.')
|
|
except Exception as e:
|
|
print(f'[prestart] WARNING: Could not set crm_runtime password: {e}')
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
asyncio.run(set_password())
|
|
"
|
|
fi
|
|
|
|
echo "[prestart] Syncing plugin table schemas..."
|
|
python3 /app/scripts/sync_plugin_schema.py
|
|
|
|
echo "[prestart] Seeding admin user if not exists..."
|
|
python3 /app/scripts/seed_admin.py || echo "[prestart] WARNING: Admin seed failed (may already exist)"
|
|
|
|
echo "[prestart] Auto-installing and activating discovered plugins..."
|
|
python3 -c "
|
|
import asyncio
|
|
from app.plugins.registry import get_registry
|
|
from app.core.db import get_session_factory
|
|
from sqlalchemy import select
|
|
from app.models.plugin import Plugin as PluginModel
|
|
|
|
async def auto_activate_plugins():
|
|
r = get_registry()
|
|
discovered = r.discover_builtins()
|
|
factory = get_session_factory()
|
|
async with factory() as db:
|
|
for name in discovered:
|
|
result = await db.execute(select(PluginModel).where(PluginModel.name == name))
|
|
record = result.scalar_one_or_none()
|
|
try:
|
|
if record is None:
|
|
await r.install(db, name)
|
|
await r.activate(db, name)
|
|
print(f'[prestart] Installed + activated plugin: {name}')
|
|
elif not record.active:
|
|
await r.activate(db, name)
|
|
print(f'[prestart] Activated plugin: {name}')
|
|
except Exception as e:
|
|
print(f'[prestart] WARNING: Could not activate plugin {name}: {e}')
|
|
await db.rollback()
|
|
await db.commit()
|
|
print('[prestart] Plugin auto-activation complete.')
|
|
|
|
asyncio.run(auto_activate_plugins())
|
|
" || echo "[prestart] WARNING: Plugin auto-activation failed"
|
|
|
|
echo "[prestart] Starting uvicorn on 0.0.0.0:8000 (workers=1)..."
|
|
exec uvicorn app.main:app \
|
|
--host 0.0.0.0 \
|
|
--port 8000 \
|
|
--workers 1
|