From 9eeecb48a34b9744bd6f90fcda91bcec17d2909b Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:44:48 +0300 Subject: [PATCH] add security test 03 --- .../0032_repair_missing_email_columns.py | 57 +++++++++++++++++++ context/11_test_runbook.md | 1 + scripts/ops/prod_security_audit.sh | 40 ++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/0032_repair_missing_email_columns.py diff --git a/alembic/versions/0032_repair_missing_email_columns.py b/alembic/versions/0032_repair_missing_email_columns.py new file mode 100644 index 0000000..a7add8a --- /dev/null +++ b/alembic/versions/0032_repair_missing_email_columns.py @@ -0,0 +1,57 @@ +"""repair missing email auth columns in legacy prod databases + +Revision ID: 0032_email_cols_fix +Revises: 0031_pii_retention_and_consent +Create Date: 2026-03-02 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0032_email_cols_fix" +down_revision = "0031_pii_retention_and_consent" +branch_labels = None +depends_on = None + + +def _has_column(inspector: sa.Inspector, table: str, column: str) -> bool: + return any(str(col.get("name")) == column for col in inspector.get_columns(table)) + + +def _has_index(inspector: sa.Inspector, table: str, index_name: str) -> bool: + return any(str(idx.get("name")) == index_name for idx in inspector.get_indexes(table)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _has_column(inspector, "clients", "email"): + op.add_column("clients", sa.Column("email", sa.String(length=255), nullable=True)) + inspector = sa.inspect(bind) + if not _has_index(inspector, "clients", "ix_clients_email"): + op.create_index("ix_clients_email", "clients", ["email"], unique=False) + + if not _has_column(inspector, "requests", "client_email"): + op.add_column("requests", sa.Column("client_email", sa.String(length=255), nullable=True)) + inspector = sa.inspect(bind) + if not _has_index(inspector, "requests", "ix_requests_client_email"): + op.create_index("ix_requests_client_email", "requests", ["client_email"], unique=False) + + if not _has_column(inspector, "otp_sessions", "channel"): + op.add_column("otp_sessions", sa.Column("channel", sa.String(length=16), nullable=True, server_default="SMS")) + if not _has_column(inspector, "otp_sessions", "email"): + op.add_column("otp_sessions", sa.Column("email", sa.String(length=255), nullable=True)) + op.execute("UPDATE otp_sessions SET channel = 'SMS' WHERE channel IS NULL") + op.alter_column("otp_sessions", "channel", nullable=False, server_default=None) + inspector = sa.inspect(bind) + if not _has_index(inspector, "otp_sessions", "ix_otp_sessions_email"): + op.create_index("ix_otp_sessions_email", "otp_sessions", ["email"], unique=False) + + +def downgrade() -> None: + # Intentionally no-op for safety on heterogeneous legacy databases. + pass diff --git a/context/11_test_runbook.md b/context/11_test_runbook.md index 0f81cd2..b6d0ca2 100644 --- a/context/11_test_runbook.md +++ b/context/11_test_runbook.md @@ -206,3 +206,4 @@ echo $? # 0=OK, >0=ALERT - `make security-smoke` — `PASS`, отчет: `reports/security/security-smoke-20260302-125811.md`. - `./scripts/ops/security_smoke.sh https://ruakb.online` — `PASS`, отчет: `reports/security/security-smoke-20260302-130511.md` (TLS и security headers внешнего контура). - `./scripts/ops/security_smoke.sh https://ruakb.ru` — `PASS`, отчет: `reports/security/security-smoke-20260302-130536.md` (TLS и security headers внешнего контура). +- `docker compose run --rm backend alembic upgrade head` — успешно после hotfix-миграции `0032_email_cols_fix` (ремонт legacy-схемы: `requests.client_email`, `otp_sessions.channel/email`, индексы). diff --git a/scripts/ops/prod_security_audit.sh b/scripts/ops/prod_security_audit.sh index c7fc7c5..bbffc53 100755 --- a/scripts/ops/prod_security_audit.sh +++ b/scripts/ops/prod_security_audit.sh @@ -78,8 +78,20 @@ ensure_compose_files() { } stack_up_and_migrate() { - log "Starting production stack (nginx profile)" - "${PROD_COMPOSE[@]}" up -d --build --remove-orphans db redis minio backend chat-service email-service worker beat frontend edge clamav + log "Starting core infra services" + "${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate db redis minio clamav + + log "Starting app services (backend/chat/email/worker/beat)" + "${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate backend chat-service email-service worker beat + + log "Waiting app services to become healthy" + wait_service_healthy "backend" 60 + wait_service_healthy "chat-service" 60 + wait_service_healthy "email-service" 60 + + # Force recreate frontend/edge after chat/backend to avoid stale DNS upstream cache in nginx. + log "Starting/recreating frontend and edge" + "${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate frontend edge log "Applying migrations" "${PROD_COMPOSE[@]}" exec -T backend alembic upgrade head @@ -94,6 +106,30 @@ print("production security config validation: ok") PY } +wait_service_healthy() { + local service="$1" + local max_attempts="${2:-60}" + local attempt=1 + local cid="" + local status="" + + while (( attempt <= max_attempts )); do + cid="$("${PROD_COMPOSE[@]}" ps -q "$service" 2>/dev/null || true)" + if [[ -n "$cid" ]]; then + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid" 2>/dev/null || true)" + case "$status" in + healthy|running) + return 0 + ;; + esac + fi + sleep 2 + attempt=$((attempt + 1)) + done + + fail "Service '${service}' did not become healthy in time (last_status=${status:-unknown})" +} + run_local_smoke() { if [[ "$SKIP_LOCAL_SMOKE" == "1" ]]; then log "Skipping local smoke checks (SKIP_LOCAL_SMOKE=1)"