From 8d1d031cc3112ad05ed1cc6d2fd0b40e15e61622 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 21:56:05 +0300 Subject: [PATCH 1/5] ci: isolate DEV migrations from application credentials --- .github/workflows/dev-ci.yml | 27 +- .github/workflows/dev-migration-role-ci.yml | 69 +++ docs/operations/dev-ci-migration-role.md | 94 ++++ .../dev-migration-role-university-owner.sql | 31 ++ scripts/dev_ci_migrate.py | 223 ++++++++++ scripts/tests/test_dev_ci_migrate.py | 414 ++++++++++++++++++ 6 files changed, 856 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/dev-migration-role-ci.yml create mode 100644 docs/operations/dev-ci-migration-role.md create mode 100644 docs/operations/dev-migration-role-university-owner.sql create mode 100644 scripts/dev_ci_migrate.py create mode 100644 scripts/tests/test_dev_ci_migrate.py diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 581f412f..7e1681f4 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -6,11 +6,29 @@ on: - dev workflow_dispatch: +permissions: + contents: read + +concurrency: + group: api-deploy-dev + cancel-in-progress: false + jobs: deploy: name: Deploy + if: github.ref == 'refs/heads/dev' + environment: dev runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - name: Prepare isolated DEV migration credentials + id: migration_credentials + env: + DEV_DATABASE_NAME: ${{ secrets.DEV_DATABASE_NAME }} + DEV_DATABASE_USER: ${{ secrets.DEV_DATABASE_USER }} + DEV_MIGRATION_DATABASE_USER: ${{ secrets.DEV_MIGRATION_DATABASE_USER }} + DEV_MIGRATION_DATABASE_PASSWORD: ${{ secrets.DEV_MIGRATION_DATABASE_PASSWORD }} + run: python3 scripts/dev_ci_migrate.py --prepare-payload - name: Deploy to server uses: garygrossgarten/github-action-ssh@release with: @@ -18,6 +36,7 @@ jobs: username: ${{ secrets.DEV_SERVER_USER }} password: ${{ secrets.DEV_SERVER_PASSWORD }} command: | + set +x set -eu exec 2>&1 @@ -164,8 +183,12 @@ jobs: raise SystemExit("Compose run detail serializer uses stale image") PY - docker compose -f docker-compose.dev-ci.yml run --rm -T web \ - python manage.py migrate --noinput < /dev/null + docker compose -f docker-compose.dev-ci.yml run --rm -T --no-deps \ + --entrypoint python web scripts/dev_ci_migrate.py <<'DEV_MIGRATION_PAYLOAD' + ${{ steps.migration_credentials.outputs.payload }} + DEV_MIGRATION_PAYLOAD + docker compose -f docker-compose.dev-ci.yml run --rm -T --no-deps web \ + python manage.py migrate --check < /dev/null docker compose -f docker-compose.dev-ci.yml run --rm -T web \ python manage.py showmigrations vacancy < /dev/null diff --git a/.github/workflows/dev-migration-role-ci.yml b/.github/workflows/dev-migration-role-ci.yml new file mode 100644 index 00000000..0994d940 --- /dev/null +++ b/.github/workflows/dev-migration-role-ci.yml @@ -0,0 +1,69 @@ +name: DEV migration role CI +on: + pull_request: + branches: [dev] + paths: + - .github/workflows/dev-ci.yml + - .github/workflows/dev-migration-role-ci.yml + - scripts/dev_ci_migrate.py + - scripts/tests/test_dev_ci_migrate.py + - docs/operations/dev-migration-role-university-owner.sql + workflow_dispatch: +permissions: + contents: read +jobs: + role-tests: + name: DEV migration role / PostgreSQL ${{ matrix.postgres }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + postgres: ["14", "18"] + services: + postgres: + image: postgres:${{ matrix.postgres }}-alpine + env: + POSTGRES_DB: postgres + POSTGRES_USER: dev_ci_test_admin + POSTGRES_PASSWORD: ci-only-disposable-password + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U dev_ci_test_admin -d postgres" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + RUN_DEV_MIGRATION_PG_TESTS: "1" + MIGRATION_TEST_PORT: "5432" + MIGRATION_TEST_ADMIN_USER: dev_ci_test_admin + MIGRATION_TEST_ADMIN_PASSWORD: ci-only-disposable-password + PYTHONIOENCODING: utf-8 + DEV_DATABASE_NAME: procollab_db_dev + DEV_DATABASE_USER: procollab_api_dev + DEV_MIGRATION_DATABASE_USER: procollab_api + DEV_MIGRATION_DATABASE_PASSWORD: | + ci-only 'quoted' "$()" `literal` + second-line + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: python -m pip install Django==4.2.11 psycopg2-binary==2.9.9 + - name: Prepare masked transport fixture + id: credentials_fixture + run: python scripts/dev_ci_migrate.py --prepare-payload + - name: Verify same-job masked output transport + env: + TRANSPORT_PAYLOAD: ${{ steps.credentials_fixture.outputs.payload }} + run: | + python - <<'PY' + import importlib.util + import os + spec = importlib.util.spec_from_file_location("migration", "scripts/dev_ci_migrate.py") + helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helper) + credentials = helper.decode_payload(os.environ["TRANSPORT_PAYLOAD"]) + assert credentials["password"] == os.environ["DEV_MIGRATION_DATABASE_PASSWORD"] + assert credentials["username"] == os.environ["DEV_MIGRATION_DATABASE_USER"] + PY + - run: python -m unittest discover -s scripts/tests -p 'test_dev_ci_migrate.py' -v diff --git a/docs/operations/dev-ci-migration-role.md b/docs/operations/dev-ci-migration-role.md new file mode 100644 index 00000000..d2acc5e8 --- /dev/null +++ b/docs/operations/dev-ci-migration-role.md @@ -0,0 +1,94 @@ +# DEV migration connection в CI + +## Задача и границы + +Deploy Dev применял миграции под runtime login из `.env`, что приводило к +`must be owner of table`. Теперь отдельные credentials используются только внутри +одноразового migration container. Web/Celery остаются под `procollab_api_dev`. +API, UI, модели, numbered migrations, PROD workflow и production secrets не меняются. + +Под migration-role здесь понимается существующий владелец `procollab_api` в отдельном +CI-подключении. Новый PostgreSQL login или cluster-wide membership не создаётся. +Не утверждается, что этот login доступен исключительно в DEV: проверена только DEV-база. +CI жёстко ограничивает database name и runtime user; production credentials не используются. + +## Фактическое состояние до активации, 17.10.2026 + +[Read-only inspection](https://github.com/PROCOLLAB-github/api/actions/runs/39059401759): + +- Runtime image: `c78078c26acb6585867b0b5c1a9be93abd76d45b-5ac944d0794f`. +- PostgreSQL 14.24; database `procollab_db_dev`; runtime `procollab_api_dev`. +- 95 tables / 89 sequences принадлежат `procollab_api`. +- `users_university` / `users_university_id_seq` принадлежат runtime login. +- Оба login не имеют SUPERUSER / CREATEDB / CREATEROLE / REPLICATION / BYPASSRLS. +- У владельца уже есть default ACL для runtime на новые tables/sequences/functions. +- Отдельных DEV migration secrets до задачи не было. + +## Проверяемые требования + +| AC | Наблюдаемый результат | +| --- | --- | +| M1 | Только refs/heads/dev; database строго procollab_db_dev | +| M2 | Missing secret отклоняется до SSH, без fallback на application/production secrets | +| M3 | Migration credentials не записываются в .env, CLI argv, web/Celery environment | +| M4 | Неадминистративная migration-role может изменять существующие DEV relations | +| M5 | Новые tables/sequences доступны runtime для штатных CRUD/sequence операций | +| M6 | Mixed ownership отклоняется до DDL/default-privilege изменений | +| M7 | После migrate обычное подключение проходит migrate --check | +| M8 | Конкурирующие DEV deploy выполняются последовательно; PROD не затронут | + +## CI secrets и последовательность + +GitHub Environment `dev`, deployment branch policy — только branch `dev`, без +manual reviewers. В нём используются отдельные secrets: + +- `DEV_MIGRATION_DATABASE_USER`: `procollab_api`. +- `DEV_MIGRATION_DATABASE_PASSWORD`: актуальный пароль этого login из Selectel. + +Пароль добавляется владельцем через GitHub Environment secrets; в чат, issue, PR +или repository file его передавать нельзя. GitHub API не позволяет прочитать +ранее сохранённое значение секрета. Обычные DEV_DATABASE_* остаются без изменения. + +Перед SSH runner проверяет credentials и создаёт base64 JSON, регистрирует его +через [add-mask](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#masking-a-value-in-a-log) +и передаёт как step output в stdin helper. Base64 обеспечивает только безопасный +transport, не шифрование. В payload нет host/database overrides. + +Helper проверяет фактическую identity, schema CREATE/USAGE, отсутствие admin flags, +ownership tables/sequences и текущие runtime grants внутри READ ONLY transaction. +Затем настраивает additive default privileges, выполняет migrate, проверяет отсутствие +pending migrations и доступ runtime к relations. Отдельный container выполняет +`manage.py migrate --check` с обычной `.env` до перезапуска web/Celery. + +## Одноразовая подготовка ownership + +До merge/активации администратор PostgreSQL должен выполнить +[dev-migration-role-university-owner.sql](dev-migration-role-university-owner.sql) +только в `procollab_db_dev`. Транзакция переносит ровно university table и проверяет +её owned sequence, сохраняя runtime privileges и данные. Это не повторяемый шаг CI. + +Нужны права фактического владельца и возможность передать ownership целевой роли; +server root или обычного GRANT ALL недостаточно для этого +([PostgreSQL 14 ALTER TABLE](https://www.postgresql.org/docs/14/sql-altertable.html)). +При недостатке прав или неожиданной identity транзакция откатится целиком. +Mass REASSIGN OWNED не используется: он затрагивает также shared database/tablespace +objects ([PostgreSQL](https://www.postgresql.org/docs/16/role-removal.html)). + +Нельзя мержить fail-closed workflow до добавления пароля и нормализации ownership: +иначе следующий DEV deploy ожидаемо остановится. Старая версия приложения при +ошибке preflight остаётся в работающих контейнерах. + +## Проверка + +`python -m unittest discover -s scripts/tests -p test_dev_ci_migrate.py -v` запускает +credentials tests. Реальные PostgreSQL tests требуют disposable instance и +`RUN_DEV_MIGRATION_PG_TESTS=1`; они отказываются заменять существующую тестовую БД +или роли. Матрица GitHub CI покрывает PostgreSQL 14 (версия DEV) и 18. + +Проверяются исходный ownership failure, успешные ALTER/CREATE migrations, runtime +CRUD/sequence на новой таблице, сохранность `.env`, idempotency, READ ONLY check, +mixed ownership fail-before-write, отказ admin credentials, special characters +в пароле и отсутствие пароля/traceback в сообщении об ошибке. + +Фактические результаты и ограничения активации фиксируются в PR description. +Без настоящих migration credentials live DDL проверка не объявляется выполненной. diff --git a/docs/operations/dev-migration-role-university-owner.sql b/docs/operations/dev-migration-role-university-owner.sql new file mode 100644 index 00000000..9c7be270 --- /dev/null +++ b/docs/operations/dev-migration-role-university-owner.sql @@ -0,0 +1,31 @@ +-- Одноразовая подготовка DEV администратором PostgreSQL, не часть deploy workflow. +-- Нет REASSIGN OWNED / ALTER ROLE / изменений владельцев БД или схем. +BEGIN; +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '30s'; + +DO $$ +DECLARE + previous_owner text; +BEGIN + IF current_database() <> 'procollab_db_dev' THEN + RAISE EXCEPTION 'Only procollab_db_dev is allowed'; + END IF; + SELECT pg_get_userbyid(relowner) INTO previous_owner + FROM pg_class WHERE oid = 'public.users_university'::regclass; + IF previous_owner NOT IN ('procollab_api_dev', 'procollab_api') THEN + RAISE EXCEPTION 'Unexpected users_university owner'; + END IF; + IF previous_owner = 'procollab_api_dev' THEN + ALTER TABLE ONLY public.users_university OWNER TO procollab_api; + END IF; + IF (SELECT pg_get_userbyid(relowner) FROM pg_class + WHERE oid = 'public.users_university_id_seq'::regclass) <> 'procollab_api' THEN + RAISE EXCEPTION 'University sequence owner did not follow table owner'; + END IF; +END $$; + +-- Сохраняем права runtime: до переноса он имел их как владелец этой таблицы. +GRANT ALL PRIVILEGES ON TABLE public.users_university TO procollab_api_dev; +GRANT ALL PRIVILEGES ON SEQUENCE public.users_university_id_seq TO procollab_api_dev; +COMMIT; diff --git a/scripts/dev_ci_migrate.py b/scripts/dev_ci_migrate.py new file mode 100644 index 00000000..aaa888d9 --- /dev/null +++ b/scripts/dev_ci_migrate.py @@ -0,0 +1,223 @@ +"""Изолированное подключение для миграций только DEV; runtime .env не меняется.""" + +import argparse +import base64 +import binascii +import json +import os +from pathlib import Path +import sys + + +DEV_DATABASE = "procollab_db_dev" +RUNTIME_USER = "procollab_api_dev" +MAX_PAYLOAD = 16384 + + +class MigrationSetupError(Exception): + pass + + +def require_dev(database, runtime_user): + if database != DEV_DATABASE or runtime_user != RUNTIME_USER: + raise MigrationSetupError("Expected DEV database and DEV runtime user") + + +def validate_credentials(username, password): + if not isinstance(username, str) or not username or "\0" in username: + raise MigrationSetupError("Missing or invalid DEV migration username") + if username == RUNTIME_USER: + raise MigrationSetupError("Migration role must differ from runtime role") + if not isinstance(password, str) or not password or "\0" in password: + raise MigrationSetupError("Missing or invalid DEV migration password") + + +def prepare_payload(environ, output): + require_dev(environ.get("DEV_DATABASE_NAME"), environ.get("DEV_DATABASE_USER")) + username = environ.get("DEV_MIGRATION_DATABASE_USER") + password = environ.get("DEV_MIGRATION_DATABASE_PASSWORD") + validate_credentials(username, password) + payload = base64.b64encode( + json.dumps({"username": username, "password": password}).encode("utf-8") + ).decode("ascii") + if len(payload) > MAX_PAYLOAD: + raise MigrationSetupError("DEV migration credentials payload is too large") + output_path = environ.get("GITHUB_OUTPUT") + if not output_path: + raise MigrationSetupError("GITHUB_OUTPUT is required") + # GitHub маскирует также производное значение до передачи его SSH action. + output.write(f"::add-mask::{payload}\n") + output.flush() + with open(output_path, "a", encoding="utf-8") as output_file: + output_file.write(f"payload={payload}\n") + + +def decode_payload(raw): + raw = raw.strip() + if len(raw) > MAX_PAYLOAD: + raise MigrationSetupError("DEV migration credentials payload is too large") + try: + credentials = json.loads(base64.b64decode(raw.strip(), validate=True)) + except (binascii.Error, ValueError, UnicodeDecodeError): + raise MigrationSetupError("Invalid DEV migration credentials payload") from None + if not isinstance(credentials, dict) or set(credentials) != {"username", "password"}: + raise MigrationSetupError("Invalid DEV migration credentials fields") + validate_credentials(credentials["username"], credentials["password"]) + return credentials + + +RELATIONS_SQL = """ +SELECT c.oid, c.relname, c.relkind, + pg_has_role(current_user, c.relowner, 'USAGE') +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p', 'S') + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_class'::regclass + AND d.objid = c.oid AND d.deptype = 'e' + ) +ORDER BY c.relname +""" + + +def preflight(connection, username): + with connection.cursor() as cursor: + cursor.execute("SELECT current_database(), current_user, current_schema()") + database, actual_user, schema = cursor.fetchone() + if database != DEV_DATABASE or actual_user != username or schema != "public": + raise MigrationSetupError("DEV migration connection identity mismatch") + cursor.execute( + "SELECT rolsuper, rolcreatedb, rolcreaterole, rolreplication, rolbypassrls " + "FROM pg_roles WHERE rolname = current_user" + ) + if any(cursor.fetchone()): + raise MigrationSetupError("DEV migration role must not be an administrator") + cursor.execute( + "SELECT has_schema_privilege(current_user, 'public', 'USAGE'), " + "has_schema_privilege(current_user, 'public', 'CREATE')" + ) + if not all(cursor.fetchone()): + raise MigrationSetupError("DEV migration role needs USAGE/CREATE on public") + cursor.execute(RELATIONS_SQL) + blocked = [name for _, name, _, owned in cursor.fetchall() if not owned] + if blocked: + raise MigrationSetupError("Non-owned DEV relations: " + ", ".join(blocked)) + + +def configure_defaults(connection, runtime_user): + from psycopg2 import sql + + # Только права штатного приложения на будущие DEV-объекты этой migration-role. + with connection.cursor() as cursor: + cursor.execute( + sql.SQL( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public " + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {}" + ).format(sql.Identifier(runtime_user)) + ) + cursor.execute( + sql.SQL( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public " + "GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO {}" + ).format(sql.Identifier(runtime_user)) + ) + + +def check_runtime_access(connection, runtime_user): + with connection.cursor() as cursor: + cursor.execute(RELATIONS_SQL) + relations = cursor.fetchall() + for relation_id, name, kind, _ in relations: + function = "has_sequence_privilege" if kind == "S" else "has_table_privilege" + privileges = ( + ("USAGE", "SELECT") + if kind == "S" + else ("SELECT", "INSERT", "UPDATE", "DELETE") + ) + for privilege in privileges: + cursor.execute( + f"SELECT {function}(%s, %s::oid, %s)", + [runtime_user, relation_id, privilege], + ) + if not cursor.fetchone()[0]: + raise MigrationSetupError( + f"DEV runtime role lacks {privilege} on {name}" + ) + + +def run_migrations(credentials, check_only=False): + require_dev(os.environ.get("DATABASE_NAME"), os.environ.get("DATABASE_USER")) + original = { + name: os.environ.get(name) for name in ("DATABASE_USER", "DATABASE_PASSWORD") + } + try: + os.environ["DATABASE_USER"] = credentials["username"] + os.environ["DATABASE_PASSWORD"] = credentials["password"] + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings") + import django + + django.setup() + from django.core.management import call_command + from django.db import connection, transaction + from django.db.migrations.executor import MigrationExecutor + + if connection.vendor != "postgresql": + raise MigrationSetupError("DEV migration requires PostgreSQL") + # Проверки не могут частично применить DDL или изменить permissions. + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET TRANSACTION READ ONLY") + preflight(connection, credentials["username"]) + check_runtime_access(connection, RUNTIME_USER) + executor = MigrationExecutor(connection) + executor.loader.check_consistent_history(connection) + plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) + sys.stdout.write(f"DEV migration preflight passed; pending={len(plan)}\n") + if check_only: + return + with transaction.atomic(): + configure_defaults(connection, RUNTIME_USER) + call_command("migrate", interactive=False) + executor = MigrationExecutor(connection) + if executor.migration_plan(executor.loader.graph.leaf_nodes()): + raise MigrationSetupError("DEV migrations remain unapplied") + check_runtime_access(connection, RUNTIME_USER) + connection.close() + sys.stdout.write("DEV migrations and runtime access check passed\n") + finally: + for name, value in original.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prepare-payload", action="store_true") + parser.add_argument("--check-only", action="store_true") + arguments = parser.parse_args() + credentials = None + try: + if arguments.prepare_payload: + prepare_payload(os.environ, sys.stdout) + else: + credentials = decode_payload(sys.stdin.read(MAX_PAYLOAD + 2)) + run_migrations(credentials, arguments.check_only) + except Exception as error: + # Не выводим DSN/traceback, которые могут содержать credentials. + if isinstance(error, MigrationSetupError): + message = str(error) + if credentials: + message = message.replace(credentials["password"], "[redacted]") + else: + message = f"DEV migration failed ({type(error).__name__})" + sys.stderr.write(message + "\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_dev_ci_migrate.py b/scripts/tests/test_dev_ci_migrate.py new file mode 100644 index 00000000..03958802 --- /dev/null +++ b/scripts/tests/test_dev_ci_migrate.py @@ -0,0 +1,414 @@ +"""Контракт credentials и реальные DDL/default-privileges на изолированном PostgreSQL.""" + +import base64 +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +HELPER = ROOT / "scripts" / "dev_ci_migrate.py" +SPEC = importlib.util.spec_from_file_location("dev_ci_migrate", HELPER) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +RUNTIME = "procollab_api_dev" +MIGRATOR = "procollab_api" +PASSWORD = "ci-only 'quoted' \"double\" $() `literal` \\ newline\nпароль" + + +def payload(username=MIGRATOR, password=PASSWORD): + return base64.b64encode( + json.dumps({"username": username, "password": password}).encode("utf-8") + ).decode("ascii") + + +class CredentialsTests(unittest.TestCase): + def test_special_characters_round_trip_and_masked_output(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "github-output" + environment = { + "DEV_DATABASE_NAME": "procollab_db_dev", + "DEV_DATABASE_USER": RUNTIME, + "DEV_MIGRATION_DATABASE_USER": MIGRATOR, + "DEV_MIGRATION_DATABASE_PASSWORD": PASSWORD, + "GITHUB_OUTPUT": str(target), + } + output = io.StringIO() + MODULE.prepare_payload(environment, output) + encoded = target.read_text(encoding="utf-8").strip().split("=", 1)[1] + self.assertEqual( + MODULE.decode_payload(encoded), + {"username": MIGRATOR, "password": PASSWORD}, + ) + self.assertEqual(output.getvalue(), f"::add-mask::{encoded}\n") + self.assertNotIn(PASSWORD, output.getvalue()) + + def test_missing_secret_stops_before_any_output(self): + output = io.StringIO() + with self.assertRaises(MODULE.MigrationSetupError): + MODULE.prepare_payload( + { + "DEV_DATABASE_NAME": "procollab_db_dev", + "DEV_DATABASE_USER": RUNTIME, + "DEV_MIGRATION_DATABASE_USER": MIGRATOR, + }, + output, + ) + self.assertEqual(output.getvalue(), "") + + def test_wrong_database_is_rejected(self): + with self.assertRaises(MODULE.MigrationSetupError): + MODULE.require_dev("procollab_db", RUNTIME) + + def test_runtime_role_cannot_be_migration_role(self): + with self.assertRaises(MODULE.MigrationSetupError): + MODULE.decode_payload(payload(RUNTIME)) + + def test_malformed_or_oversized_payload_is_rejected(self): + for encoded in ("not base64", "a" * (MODULE.MAX_PAYLOAD + 1)): + with self.subTest(encoded_length=len(encoded)): + with self.assertRaises(MODULE.MigrationSetupError): + MODULE.decode_payload(encoded) + + def test_payload_cannot_override_host_or_database(self): + raw = json.dumps( + { + "username": MIGRATOR, + "password": PASSWORD, + "DATABASE_HOST": "untrusted.example", + } + ).encode() + with self.assertRaises(MODULE.MigrationSetupError): + MODULE.decode_payload(base64.b64encode(raw).decode()) + + def test_environment_is_restored_after_setup_failure(self): + fake_django = mock.Mock() + fake_django.setup.side_effect = RuntimeError("injected failure") + initial = { + "DATABASE_NAME": "procollab_db_dev", + "DATABASE_USER": RUNTIME, + "DATABASE_PASSWORD": "runtime-only", + } + with mock.patch.dict(os.environ, initial), mock.patch.dict( + sys.modules, {"django": fake_django} + ): + with self.assertRaises(RuntimeError): + MODULE.run_migrations({"username": MIGRATOR, "password": PASSWORD}) + self.assertEqual(os.environ["DATABASE_USER"], RUNTIME) + self.assertEqual(os.environ["DATABASE_PASSWORD"], "runtime-only") + + def test_unexpected_error_does_not_disclose_secret_or_traceback(self): + output = io.StringIO() + with mock.patch.object(sys, "argv", [str(HELPER)]), mock.patch.object( + sys, "stdin", io.StringIO(payload()) + ), mock.patch.object(sys, "stderr", output), mock.patch.object( + MODULE, "run_migrations", side_effect=RuntimeError(PASSWORD) + ): + self.assertEqual(MODULE.main(), 1) + self.assertNotIn(PASSWORD, output.getvalue()) + self.assertNotIn("Traceback", output.getvalue()) + self.assertIn("RuntimeError", output.getvalue()) + + +@unittest.skipUnless( + os.environ.get("RUN_DEV_MIGRATION_PG_TESTS") == "1", + "Requires an explicitly enabled disposable PostgreSQL cluster", +) +class PostgreSQLMigrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + import psycopg2 + from psycopg2 import sql + + cls.sql = sql + cls.port = os.environ.get("MIGRATION_TEST_PORT", "55442") + cls.admin_user = os.environ.get("MIGRATION_TEST_ADMIN_USER", "dev_ci_test_admin") + if not cls.admin_user.startswith("dev_ci_test_"): + raise RuntimeError("Tests require a dedicated disposable admin role") + cls.admin = psycopg2.connect( + host="127.0.0.1", + port=cls.port, + dbname="postgres", + user=cls.admin_user, + password=os.environ.get("MIGRATION_TEST_ADMIN_PASSWORD", ""), + connect_timeout=10, + ) + cls.admin.autocommit = True + with cls.admin.cursor() as cursor: + cursor.execute("SELECT 1 FROM pg_database WHERE datname='procollab_db_dev'") + if cursor.fetchone(): + raise RuntimeError("Refusing to replace an existing database") + cursor.execute( + "SELECT 1 FROM pg_roles WHERE rolname IN (%s,%s)", [RUNTIME, MIGRATOR] + ) + if cursor.fetchone(): + raise RuntimeError("Refusing to alter existing database roles") + for role in (RUNTIME, MIGRATOR): + cursor.execute( + sql.SQL( + "CREATE ROLE {} LOGIN PASSWORD %s " + "NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS" + ).format(sql.Identifier(role)), + [PASSWORD], + ) + + @classmethod + def tearDownClass(cls): + with cls.admin.cursor() as cursor: + for role in (RUNTIME, MIGRATOR): + cursor.execute( + cls.sql.SQL("DROP ROLE {}").format(cls.sql.Identifier(role)) + ) + cls.admin.close() + + def setUp(self): + import psycopg2 + + with self.admin.cursor() as cursor: + cursor.execute( + self.sql.SQL("CREATE DATABASE procollab_db_dev OWNER {}").format( + self.sql.Identifier(RUNTIME) + ) + ) + self.created_database = True + self.database = psycopg2.connect( + host="127.0.0.1", + port=self.port, + dbname="procollab_db_dev", + user=self.admin_user, + password=os.environ.get("MIGRATION_TEST_ADMIN_PASSWORD", ""), + ) + self.database.autocommit = True + with self.database.cursor() as cursor: + cursor.execute( + self.sql.SQL("GRANT USAGE, CREATE ON SCHEMA public TO {}").format( + self.sql.Identifier(MIGRATOR) + ) + ) + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + fixture = Path(self.directory.name) + app = fixture / "probe_app" + (app / "migrations").mkdir(parents=True) + (app / "__init__.py").write_text("", encoding="utf-8") + (app / "migrations" / "__init__.py").write_text("", encoding="utf-8") + (app / "models.py").write_text( + "from django.db import models\n" + "class Probe(models.Model):\n" + " name = models.CharField(max_length=32)\n", + encoding="utf-8", + ) + (app / "migrations" / "0001_initial.py").write_text( + "from django.db import migrations, models\n" + "class Migration(migrations.Migration):\n" + " initial = True\n dependencies = []\n" + " operations = [migrations.CreateModel(name='Probe', fields=[" + "('id', models.AutoField(primary_key=True))," + "('name', models.CharField(max_length=32))])]\n", + encoding="utf-8", + ) + (fixture / "role_test_settings.py").write_text( + "import os\nSECRET_KEY='isolated-tests-only'\nDEBUG=False\n" + "INSTALLED_APPS=['django.contrib.contenttypes','probe_app']\n" + "DEFAULT_AUTO_FIELD='django.db.models.AutoField'\n" + "DATABASES={'default':{'ENGINE':'django.db.backends.postgresql'," + "'NAME':os.environ['DATABASE_NAME'],'USER':os.environ['DATABASE_USER']," + "'PASSWORD':os.environ['DATABASE_PASSWORD'],'HOST':'127.0.0.1'," + "'PORT':os.environ['DATABASE_PORT']}}\n", + encoding="utf-8", + ) + self.environment = dict( + os.environ, + PYTHONPATH=str(fixture), + DJANGO_SETTINGS_MODULE="role_test_settings", + DATABASE_NAME="procollab_db_dev", + DATABASE_USER=RUNTIME, + DATABASE_PASSWORD=PASSWORD, + DATABASE_PORT=self.port, + ) + seeded = subprocess.run( + [ + sys.executable, + "-m", + "django", + "migrate", + "contenttypes", + "0001", + "--noinput", + ], + env=dict(self.environment, DATABASE_USER=MIGRATOR), + cwd=ROOT, + capture_output=True, + text=True, + timeout=45, + ) + self.assertEqual(seeded.returncode, 0, seeded.stderr) + with self.database.cursor() as cursor: + cursor.execute( + self.sql.SQL( + "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO {}" + ).format(self.sql.Identifier(RUNTIME)) + ) + cursor.execute( + self.sql.SQL( + "GRANT USAGE,SELECT,UPDATE ON ALL SEQUENCES IN SCHEMA public TO {}" + ).format(self.sql.Identifier(RUNTIME)) + ) + + def tearDown(self): + if hasattr(self, "database"): + self.database.close() + if getattr(self, "created_database", False): + with self.admin.cursor() as cursor: + cursor.execute("DROP DATABASE procollab_db_dev WITH (FORCE)") + + def run_helper(self, *arguments, username=MIGRATOR): + password = ( + PASSWORD + if username == MIGRATOR + else ( + os.environ.get("MIGRATION_TEST_ADMIN_PASSWORD") + or "ci-only-disposable-password" + ) + ) + return subprocess.run( + [sys.executable, str(HELPER), *arguments], + input=payload(username, password), + env=self.environment, + cwd=self.directory.name, + capture_output=True, + text=True, + timeout=60, + ) + + def assert_still_pending(self): + with self.database.cursor() as cursor: + cursor.execute("SELECT count(*) FROM django_migrations") + self.assertEqual(cursor.fetchone()[0], 1) + cursor.execute("SELECT count(*) FROM pg_default_acl") + self.assertEqual(cursor.fetchone()[0], 0) + cursor.execute("SELECT to_regclass('probe_app_probe')") + self.assertIsNone(cursor.fetchone()[0]) + + def test_runtime_role_cannot_apply_pending_ddl(self): + result = subprocess.run( + [sys.executable, "-m", "django", "migrate", "--noinput"], + env=self.environment, + cwd=ROOT, + capture_output=True, + text=True, + timeout=45, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("must be owner", result.stderr) + self.assert_still_pending() + + def test_owner_migrates_and_runtime_can_write_new_table_and_sequence(self): + import psycopg2 + + env_path = Path(self.directory.name) / ".env" + env_path.write_text("DATABASE_USER=procollab_api_dev\n", encoding="utf-8") + before = env_path.read_bytes() + result = self.run_helper() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn(PASSWORD, result.stdout + result.stderr) + self.assertEqual(env_path.read_bytes(), before) + with psycopg2.connect( + host="127.0.0.1", + port=self.port, + dbname="procollab_db_dev", + user=RUNTIME, + password=PASSWORD, + ) as runtime: + with runtime.cursor() as cursor: + cursor.execute("SELECT current_user") + self.assertEqual(cursor.fetchone()[0], RUNTIME) + cursor.execute( + "INSERT INTO probe_app_probe(name) VALUES('first') RETURNING id" + ) + self.assertGreater(cursor.fetchone()[0], 0) + cursor.execute("UPDATE probe_app_probe SET name='second'") + cursor.execute("SELECT name FROM probe_app_probe") + self.assertEqual(cursor.fetchone()[0], "second") + cursor.execute("DELETE FROM probe_app_probe") + repeated = self.run_helper() + self.assertEqual(repeated.returncode, 0, repeated.stderr) + self.assertIn("pending=0", repeated.stdout) + + def test_mixed_ownership_fails_before_ddl_and_default_privileges(self): + with self.database.cursor() as cursor: + cursor.execute("CREATE TABLE users_university(id serial PRIMARY KEY)") + cursor.execute( + self.sql.SQL("ALTER TABLE users_university OWNER TO {}").format( + self.sql.Identifier(RUNTIME) + ) + ) + result = self.run_helper() + self.assertNotEqual(result.returncode, 0) + self.assertIn("users_university", result.stderr) + self.assertNotIn(PASSWORD, result.stdout + result.stderr) + self.assert_still_pending() + + def test_check_only_does_not_change_schema_or_defaults(self): + result = self.run_helper("--check-only") + self.assertEqual(result.returncode, 0, result.stderr) + self.assert_still_pending() + + def test_admin_credentials_are_rejected(self): + result = self.run_helper(username=self.admin_user) + self.assertNotEqual(result.returncode, 0) + self.assertIn("must not be an administrator", result.stderr) + self.assert_still_pending() + + def test_university_owner_normalization_preserves_data_and_runtime_access(self): + import psycopg2 + + with self.database.cursor() as cursor: + cursor.execute( + "CREATE TABLE users_university(id serial PRIMARY KEY, name text)" + ) + cursor.execute( + "INSERT INTO users_university(name) VALUES('existing university')" + ) + cursor.execute( + self.sql.SQL("ALTER TABLE users_university OWNER TO {}").format( + self.sql.Identifier(RUNTIME) + ) + ) + cursor.execute( + ( + ROOT / "docs/operations/dev-migration-role-university-owner.sql" + ).read_text(encoding="utf-8") + ) + cursor.execute( + "SELECT pg_get_userbyid(relowner) FROM pg_class " + "WHERE oid='users_university'::regclass" + ) + self.assertEqual(cursor.fetchone()[0], MIGRATOR) + with psycopg2.connect( + host="127.0.0.1", + port=self.port, + dbname="procollab_db_dev", + user=RUNTIME, + password=PASSWORD, + ) as runtime: + with runtime.cursor() as cursor: + cursor.execute("SELECT name FROM users_university") + self.assertEqual(cursor.fetchall(), [("existing university",)]) + cursor.execute( + "INSERT INTO users_university(name) VALUES('new university') RETURNING id" + ) + self.assertEqual(cursor.fetchone()[0], 2) + result = self.run_helper() + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() From 194d105a9802752892ece474603e23addafd95dc Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 21:58:52 +0300 Subject: [PATCH 2/5] test: verify runtime checks and non-DEV refusal --- scripts/tests/test_dev_ci_migrate.py | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/tests/test_dev_ci_migrate.py b/scripts/tests/test_dev_ci_migrate.py index 03958802..8dbbb0e5 100644 --- a/scripts/tests/test_dev_ci_migrate.py +++ b/scripts/tests/test_dev_ci_migrate.py @@ -341,6 +341,15 @@ def test_owner_migrates_and_runtime_can_write_new_table_and_sequence(self): repeated = self.run_helper() self.assertEqual(repeated.returncode, 0, repeated.stderr) self.assertIn("pending=0", repeated.stdout) + runtime_check = subprocess.run( + [sys.executable, "-m", "django", "migrate", "--check"], + env=self.environment, + cwd=ROOT, + capture_output=True, + text=True, + timeout=45, + ) + self.assertEqual(runtime_check.returncode, 0, runtime_check.stderr) def test_mixed_ownership_fails_before_ddl_and_default_privileges(self): with self.database.cursor() as cursor: @@ -409,6 +418,39 @@ def test_university_owner_normalization_preserves_data_and_runtime_access(self): result = self.run_helper() self.assertEqual(result.returncode, 0, result.stderr) + def test_wrong_database_is_rejected_before_connecting(self): + result = subprocess.run( + [sys.executable, str(HELPER)], + input=payload(), + env=dict( + self.environment, DATABASE_NAME="not_dev", DATABASE_HOST="invalid.example" + ), + cwd=ROOT, + capture_output=True, + text=True, + timeout=10, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Expected DEV database", result.stderr) + self.assert_still_pending() + + def test_normalizer_refuses_non_dev_database(self): + import psycopg2 + + with self.admin.cursor() as cursor: + try: + with self.assertRaises(psycopg2.Error) as captured: + cursor.execute( + ( + ROOT + / "docs/operations/dev-migration-role-university-owner.sql" + ).read_text(encoding="utf-8") + ) + self.assertIn("Only procollab_db_dev", str(captured.exception)) + finally: + cursor.execute("ROLLBACK") + self.assert_still_pending() + if __name__ == "__main__": unittest.main() From e5a0e8c5d9d1422ee672db26236d5343393cbcf2 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 22:19:20 +0300 Subject: [PATCH 3/5] ci: stream DEV deployment over pinned SSH stdin --- .github/workflows/dev-ci.yml | 20 +++-- .github/workflows/dev-migration-role-ci.yml | 11 +++ docs/operations/dev-ci-migration-role.md | 14 +++ scripts/dev_ci_migrate.py | 2 +- scripts/dev_ci_ssh.cjs | 86 ++++++++++++++++++ scripts/tests/test_dev_ci_ssh.cjs | 96 +++++++++++++++++++++ 6 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 scripts/dev_ci_ssh.cjs create mode 100644 scripts/tests/test_dev_ci_ssh.cjs diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 7e1681f4..e55cd410 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -29,13 +29,19 @@ jobs: DEV_MIGRATION_DATABASE_USER: ${{ secrets.DEV_MIGRATION_DATABASE_USER }} DEV_MIGRATION_DATABASE_PASSWORD: ${{ secrets.DEV_MIGRATION_DATABASE_PASSWORD }} run: python3 scripts/dev_ci_migrate.py --prepare-payload - - name: Deploy to server - uses: garygrossgarten/github-action-ssh@release + - uses: actions/setup-node@v4 with: - host: ${{ secrets.DEV_SERVER_HOST }} - username: ${{ secrets.DEV_SERVER_USER }} - password: ${{ secrets.DEV_SERVER_PASSWORD }} - command: | + node-version: "24" + - name: Install DEV SSH transport + run: npm install --prefix "$RUNNER_TEMP/dev-migration-ssh" --ignore-scripts --no-package-lock --no-audit --no-fund ssh2@1.16.0 + - name: Deploy to server + env: + NODE_PATH: ${{ runner.temp }}/dev-migration-ssh/node_modules + DEV_SERVER_HOST: ${{ secrets.DEV_SERVER_HOST }} + DEV_SERVER_USER: ${{ secrets.DEV_SERVER_USER }} + DEV_SERVER_PASSWORD: ${{ secrets.DEV_SERVER_PASSWORD }} + DEV_SSH_HOST_KEY_SHA256: ${{ vars.DEV_SSH_HOST_KEY_SHA256 }} + DEV_DEPLOY_SCRIPT: | set +x set -eu exec 2>&1 @@ -51,7 +57,6 @@ jobs: git status --short --untracked-files=no >&2 exit 1 fi - expected_serializer_blob="$(git rev-parse HEAD:vacancy/serializers.py)" actual_serializer_blob="$(git hash-object vacancy/serializers.py)" if [ "$actual_serializer_blob" != "$expected_serializer_blob" ]; then @@ -345,3 +350,4 @@ jobs: docker compose -f docker-compose.dev-ci.yml logs --tail=100 web >&2 || true exit 1 fi + run: node scripts/dev_ci_ssh.cjs diff --git a/.github/workflows/dev-migration-role-ci.yml b/.github/workflows/dev-migration-role-ci.yml index 0994d940..a9b47030 100644 --- a/.github/workflows/dev-migration-role-ci.yml +++ b/.github/workflows/dev-migration-role-ci.yml @@ -6,7 +6,9 @@ on: - .github/workflows/dev-ci.yml - .github/workflows/dev-migration-role-ci.yml - scripts/dev_ci_migrate.py + - scripts/dev_ci_ssh.cjs - scripts/tests/test_dev_ci_migrate.py + - scripts/tests/test_dev_ci_ssh.cjs - docs/operations/dev-migration-role-university-owner.sql workflow_dispatch: permissions: @@ -45,6 +47,15 @@ jobs: second-line steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Install DEV SSH transport + run: npm install --prefix "$RUNNER_TEMP/dev-migration-ssh" --ignore-scripts --no-package-lock --no-audit --no-fund ssh2@1.16.0 + - name: Verify SSH stdin transport and host pinning + env: + NODE_PATH: ${{ runner.temp }}/dev-migration-ssh/node_modules + run: node --test scripts/tests/test_dev_ci_ssh.cjs - uses: actions/setup-python@v5 with: python-version: "3.11" diff --git a/docs/operations/dev-ci-migration-role.md b/docs/operations/dev-ci-migration-role.md index d2acc5e8..c4295010 100644 --- a/docs/operations/dev-ci-migration-role.md +++ b/docs/operations/dev-ci-migration-role.md @@ -45,6 +45,11 @@ manual reviewers. В нём используются отдельные secrets: - `DEV_MIGRATION_DATABASE_USER`: `procollab_api`. - `DEV_MIGRATION_DATABASE_PASSWORD`: актуальный пароль этого login из Selectel. +Environment variable `DEV_SSH_HOST_KEY_SHA256` содержит публичный ED25519 fingerprint +DEV-сервера, проверенный отдельной read-only inspection. Это не пароль. При ротации +host key сначала независимо проверяется новый публичный ключ, затем обновляется +эта переменная; отключать host verification нельзя. + Пароль добавляется владельцем через GitHub Environment secrets; в чат, issue, PR или repository file его передавать нельзя. GitHub API не позволяет прочитать ранее сохранённое значение секрета. Обычные DEV_DATABASE_* остаются без изменения. @@ -54,6 +59,11 @@ manual reviewers. В нём используются отдельные secrets: и передаёт как step output в stdin helper. Base64 обеспечивает только безопасный transport, не шифрование. В payload нет host/database overrides. +`scripts/dev_ci_ssh.cjs` использует pinned `ssh2@1.16.0`, проверяет host fingerprint +и запускает только фиксированный `bash -se`. Весь deploy script передаётся через +SSH stdin: secret-bearing script не попадает в argv удалённого shell и не +записывается во временный файл. Старый SSH action заменён только в DEV workflow. + Helper проверяет фактическую identity, schema CREATE/USAGE, отсутствие admin flags, ownership tables/sequences и текущие runtime grants внутри READ ONLY transaction. Затем настраивает additive default privileges, выполняет migrate, проверяет отсутствие @@ -85,6 +95,10 @@ credentials tests. Реальные PostgreSQL tests требуют disposable i `RUN_DEV_MIGRATION_PG_TESTS=1`; они отказываются заменять существующую тестовую БД или роли. Матрица GitHub CI покрывает PostgreSQL 14 (версия DEV) и 18. +`node --test scripts/tests/test_dev_ci_ssh.cjs` с установленным `ssh2@1.16.0` +проверяет реальное loopback SSH соединение: stdin вместо argv, отказ чужому host +key до authentication, missing config, controlled remote failure и timeout. + Проверяются исходный ownership failure, успешные ALTER/CREATE migrations, runtime CRUD/sequence на новой таблице, сохранность `.env`, idempotency, READ ONLY check, mixed ownership fail-before-write, отказ admin credentials, special characters diff --git a/scripts/dev_ci_migrate.py b/scripts/dev_ci_migrate.py index aaa888d9..92853dd3 100644 --- a/scripts/dev_ci_migrate.py +++ b/scripts/dev_ci_migrate.py @@ -45,7 +45,7 @@ def prepare_payload(environ, output): output_path = environ.get("GITHUB_OUTPUT") if not output_path: raise MigrationSetupError("GITHUB_OUTPUT is required") - # GitHub маскирует также производное значение до передачи его SSH action. + # GitHub маскирует также производное значение до передачи его SSH transport. output.write(f"::add-mask::{payload}\n") output.flush() with open(output_path, "a", encoding="utf-8") as output_file: diff --git a/scripts/dev_ci_ssh.cjs b/scripts/dev_ci_ssh.cjs new file mode 100644 index 00000000..5cb48136 --- /dev/null +++ b/scripts/dev_ci_ssh.cjs @@ -0,0 +1,86 @@ +// Keep deployment secrets out of the remote shell's command-line arguments. +const { createHash } = require('node:crypto'); +const { Client } = require('ssh2'); + +function fingerprint(key) { + return `SHA256:${createHash('sha256').update(key).digest('base64').replace(/=+$/, '')}`; +} + +function validate(config) { + for (const name of ['host', 'username', 'password', 'script']) { + if (typeof config[name] !== 'string' || !config[name].trim()) { + throw new Error('DEV SSH configuration is incomplete'); + } + } + if (!/^SHA256:[A-Za-z0-9+/]{43}$/.test(config.hostFingerprint || '')) { + throw new Error('DEV SSH host fingerprint is missing or invalid'); + } +} + +async function deploy(config, stdout = process.stdout, stderr = process.stderr) { + validate(config); + return new Promise((resolve, reject) => { + const client = new Client(); + let settled = false; + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + client.end(); + if (error) reject(error); + else resolve(); + }; + const timer = setTimeout(() => { + finish(new Error('DEV SSH deployment timed out')); + client.destroy(); + }, config.timeoutMs || 30 * 60 * 1000); + + client.on('error', () => finish(new Error('DEV SSH connection failed'))); + client.on('close', () => finish(new Error('DEV SSH connection closed before completion'))); + client.on('ready', () => { + // Only this fixed command appears in sshd/bash argv. The script travels on stdin. + client.exec('bash -se', (error, stream) => { + if (error) return finish(new Error('DEV SSH command could not start')); + stream.on('data', (chunk) => stdout.write(chunk)); + stream.stderr.on('data', (chunk) => stderr.write(chunk)); + stream.on('error', () => finish(new Error('DEV SSH command stream failed'))); + stream.on('close', (code, signal) => { + if (code === 0 && !signal) finish(); + else finish(new Error('DEV remote deployment failed; inspect masked command output')); + }); + stream.end(config.script.endsWith('\n') ? config.script : `${config.script}\n`); + }); + }); + try { + client.connect({ + host: config.host, + port: config.port || 22, + username: config.username, + password: config.password, + // DEV's pinned public host key is ED25519. Never accept another key implicitly. + algorithms: { serverHostKey: ['ssh-ed25519'] }, + hostVerifier: (key) => fingerprint(key) === config.hostFingerprint, + readyTimeout: 20000, + keepaliveInterval: 15000, + }); + } catch { + finish(new Error('DEV SSH connection configuration failed')); + } + }); +} + +if (require.main === module) { + deploy({ + host: process.env.DEV_SERVER_HOST, + username: process.env.DEV_SERVER_USER, + password: process.env.DEV_SERVER_PASSWORD, + hostFingerprint: process.env.DEV_SSH_HOST_KEY_SHA256, + script: process.env.DEV_DEPLOY_SCRIPT, + }).catch((error) => { + // All errors above are fixed messages, without credentials, script or stack. + console.error(error.message); + process.exitCode = 1; + }); +} + +module.exports = { deploy, fingerprint }; diff --git a/scripts/tests/test_dev_ci_ssh.cjs b/scripts/tests/test_dev_ci_ssh.cjs new file mode 100644 index 00000000..895974c6 --- /dev/null +++ b/scripts/tests/test_dev_ci_ssh.cjs @@ -0,0 +1,96 @@ +const assert = require('node:assert/strict'); +const { once } = require('node:events'); +const { PassThrough } = require('node:stream'); +const { test } = require('node:test'); +const { Server, utils } = require('ssh2'); +const { deploy, fingerprint } = require('../dev_ci_ssh.cjs'); + +// Ephemeral keys and credentials exist only in memory, for a loopback SSH server. +const keys = utils.generateKeyPairSync('ed25519'); +const hostFingerprint = fingerprint(utils.parseKey(keys.private).getPublicSSH()); +const password = 'fixture-only-password'; +const script = 'set +x\nset -eu\ncat <<\'PAYLOAD\'\nfixture $() `secret`\nPAYLOAD\n'; + +async function fixture(context, options = {}) { + const observed = { commands: [], input: '', authenticated: false }; + const connections = new Set(); + const server = new Server({ hostKeys: [keys.private] }, (connection) => { + connections.add(connection); + connection.on('error', () => {}); + connection.on('close', () => connections.delete(connection)); + connection.on('authentication', (auth) => { + observed.authenticated = true; + if (auth.method === 'password' && auth.username === 'fixture' && auth.password === password) { + auth.accept(); + } else auth.reject(); + }); + connection.on('ready', () => connection.on('session', (accept) => { + const session = accept(); + session.on('exec', (acceptCommand, rejectCommand, info) => { + observed.commands.push(info.command); + const stream = acceptCommand(); + stream.on('data', (chunk) => { observed.input += chunk.toString(); }); + stream.on('end', () => { + if (options.hang) return; + stream.write('remote stdout\n'); + stream.stderr.write('remote stderr\n'); + stream.exit(options.exitCode || 0); + stream.end(); + }); + }); + })); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + context.after(async () => { + for (const connection of connections) connection.end(); + await new Promise((resolve) => server.close(resolve)); + }); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let output = ''; + stdout.on('data', (chunk) => { output += chunk.toString(); }); + stderr.on('data', (chunk) => { output += chunk.toString(); }); + return { + config: { host: '127.0.0.1', port: server.address().port, username: 'fixture', password, hostFingerprint, script }, + observed, stdout, stderr, output: () => output, + }; +} + +test('exec argv is fixed; full secret-bearing script is delivered only on stdin', async (context) => { + const f = await fixture(context); + await deploy(f.config, f.stdout, f.stderr); + assert.deepEqual(f.observed.commands, ['bash -se']); + assert.equal(f.observed.input, script); + assert.equal(f.output(), 'remote stdout\nremote stderr\n'); + assert.ok(!f.output().includes('fixture $()')); +}); + +test('wrong host fingerprint rejects before authentication or script delivery', async (context) => { + const f = await fixture(context); + await assert.rejects(deploy({ ...f.config, hostFingerprint: `SHA256:${'A'.repeat(43)}` }, f.stdout, f.stderr), /connection failed/); + assert.equal(f.observed.authenticated, false); + assert.deepEqual(f.observed.commands, []); + assert.equal(f.observed.input, ''); +}); + +test('missing fingerprint and empty credentials fail before connecting', async () => { + const config = { host: '127.0.0.1', username: 'fixture', password, script }; + await assert.rejects(deploy(config), /host fingerprint/); + await assert.rejects(deploy({ ...config, hostFingerprint, password: '' }), /configuration is incomplete/); +}); + +test('remote failure propagates without including the script or credentials', async (context) => { + const f = await fixture(context, { exitCode: 7 }); + await assert.rejects(deploy(f.config, f.stdout, f.stderr), (error) => { + assert.match(error.message, /remote deployment failed/); + assert.ok(!error.message.includes(password)); + assert.ok(!error.message.includes(script)); + return true; + }); +}); + +test('deployment timeout closes the SSH connection', async (context) => { + const f = await fixture(context, { hang: true }); + await assert.rejects(deploy({ ...f.config, timeoutMs: 1000 }, f.stdout, f.stderr), /timed out/); +}); From 83d5bf4dbcb95b24b4c70684233d1be451f397b9 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 00:56:02 +0300 Subject: [PATCH 4/5] docs: record shared migration role and verified PROD identity --- docs/operations/dev-ci-migration-role.md | 27 +++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/operations/dev-ci-migration-role.md b/docs/operations/dev-ci-migration-role.md index c4295010..13f7a2c1 100644 --- a/docs/operations/dev-ci-migration-role.md +++ b/docs/operations/dev-ci-migration-role.md @@ -9,9 +9,34 @@ API, UI, модели, numbered migrations, PROD workflow и production secrets Под migration-role здесь понимается существующий владелец `procollab_api` в отдельном CI-подключении. Новый PostgreSQL login или cluster-wide membership не создаётся. -Не утверждается, что этот login доступен исключительно в DEV: проверена только DEV-база. CI жёстко ограничивает database name и runtime user; production credentials не используются. +## Согласованная схема DEV / PROD, 18.10.2026 + +Пользователь выбрал `procollab_api` для миграций обоих контуров, сохранив +существующие подключения web/Celery. [Read-only preflight обоих контуров](https://github.com/PROCOLLAB-github/api/actions/runs/39072746180) +проверил actual connection identity, ownership и role flags в READ ONLY transaction: + +| Контур | Фактическая БД | Web/Celery сейчас | Миграции сейчас | Целевая роль миграций | +| --- | --- | --- | --- | --- | +| DEV | `procollab_db_dev` | `procollab_api_dev` | `procollab_api_dev` | `procollab_api` | +| PROD | `procollab_db_test` | `procollab_api` | `procollab_api` | `procollab_api` — уже используется | + +Название `procollab_db_test` здесь относится к фактическому deployed PROD: +контур установлен по серверному checkout и работающему production image, а не по имени БД. +Одинаковый `pg_control_system().system_identifier` подтвердил общий PostgreSQL-кластер. +Следовательно, `procollab_api` — одна существующая cluster-wide роль, а не два +независимых login с одинаковым названием. Её пароль не ротируется в рамках задачи. + +В PROD все 107 tables / 100 sequences принадлежат `procollab_api`; не принадлежащих +ей relations нет. Для выбранной роли изменения PROD workflow, ownership и grants +не требуются. Существующий PROD runtime тоже использует эту роль; разделение +runtime/migration прав в PROD не заявляется и в scope не входит. + +В DEV остаётся одноразовая подготовка university table/sequence, описанная ниже. +Секрет `DEV_MIGRATION_DATABASE_PASSWORD` добавлен пользователем; наличие подтверждено +по metadata, значение не читалось и live owner authentication пока не проверена. + ## Фактическое состояние до активации, 17.10.2026 [Read-only inspection](https://github.com/PROCOLLAB-github/api/actions/runs/39059401759): From f737debaf2fc9d0656369f803c6506b6879c98d8 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 01:41:24 +0300 Subject: [PATCH 5/5] fix: prepare DEV ownership with existing PostgreSQL 14 logins --- .github/workflows/dev-migration-role-ci.yml | 1 + docs/operations/dev-ci-migration-role.md | 33 ++- .../dev-migration-role-university-owner.sql | 2 +- scripts/dev_ci_prepare_owner.py | 196 ++++++++++++++++++ scripts/tests/test_dev_ci_migrate.py | 170 +++++++++++++++ 5 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 scripts/dev_ci_prepare_owner.py diff --git a/.github/workflows/dev-migration-role-ci.yml b/.github/workflows/dev-migration-role-ci.yml index a9b47030..605f314d 100644 --- a/.github/workflows/dev-migration-role-ci.yml +++ b/.github/workflows/dev-migration-role-ci.yml @@ -6,6 +6,7 @@ on: - .github/workflows/dev-ci.yml - .github/workflows/dev-migration-role-ci.yml - scripts/dev_ci_migrate.py + - scripts/dev_ci_prepare_owner.py - scripts/dev_ci_ssh.cjs - scripts/tests/test_dev_ci_migrate.py - scripts/tests/test_dev_ci_ssh.cjs diff --git a/docs/operations/dev-ci-migration-role.md b/docs/operations/dev-ci-migration-role.md index 13f7a2c1..225c910a 100644 --- a/docs/operations/dev-ci-migration-role.md +++ b/docs/operations/dev-ci-migration-role.md @@ -8,8 +8,9 @@ Deploy Dev применял миграции под runtime login из `.env`, API, UI, модели, numbered migrations, PROD workflow и production secrets не меняются. Под migration-role здесь понимается существующий владелец `procollab_api` в отдельном -CI-подключении. Новый PostgreSQL login или cluster-wide membership не создаётся. -CI жёстко ограничивает database name и runtime user; production credentials не используются. +CI-подключении. Новый PostgreSQL login не создаётся. Одноразовая подготовка ниже +использует временное членство DEV role → migration role с последующим отзывом. +CI жёстко ограничивает database name и runtime user; secrets PROD workflow не читаются. ## Согласованная схема DEV / PROD, 18.10.2026 @@ -97,12 +98,34 @@ pending migrations и доступ runtime к relations. Отдельный cont ## Одноразовая подготовка ownership -До merge/активации администратор PostgreSQL должен выполнить -[dev-migration-role-university-owner.sql](dev-migration-role-university-owner.sql) +До merge/активации нужно выполнить [dev-migration-role-university-owner.sql](dev-migration-role-university-owner.sql) только в `procollab_db_dev`. Транзакция переносит ровно university table и проверяет её owned sequence, сохраняя runtime privileges и данные. Это не повторяемый шаг CI. -Нужны права фактического владельца и возможность передать ownership целевой роли; +На фактической PostgreSQL 14 отдельный администратор не обязателен: +[GRANT PostgreSQL 14](https://www.postgresql.org/docs/14/sql-grant.html) разрешает +session_user выдавать членство в собственной роли. Это отдельно проверено на +[disposable PostgreSQL 14.24](https://github.com/PROCOLLAB-github/api/actions/runs/39075480886). +Поведение PostgreSQL 18 отличается; helper отказывается применять эту процедуру к другим major versions. + +`scripts/dev_ci_prepare_owner.py` использует два существующих login: + +1. Спрашивает только скрытый пароль `procollab_api`; runtime credentials берёт из environment контейнера. +2. Подключается к DEV под `procollab_api_dev` и `procollab_api`, проверяет обе identity и PostgreSQL 14. +3. При необходимости временно делает `GRANT procollab_api_dev TO procollab_api`. +4. Под `procollab_api` выполняет проверенный SQL смены владельца и runtime grants. +5. В `finally` отзывает только добавленное членство; существующее членство не удаляет. + +Направление grant — DEV role → migration role. Runtime DEV не становится членом +PROD owner-role. Членство действует на кластер, поэтому временно расширяются права +общей `procollab_api` на DEV; PROD objects и grants не меняются. Advisory lock не +позволяет двум экземплярам helper конкурировать. Ошибки SQL откатывают незавершённую +транзакцию перед отзывом членства; SIGINT/SIGTERM/SIGHUP выполняют cleanup. +При принудительном убийстве процесса или недоступности БД cleanup нельзя гарантировать: +перед продолжением проверить членство и при необходимости под `procollab_api_dev` +выполнить `REVOKE procollab_api_dev FROM procollab_api`. + +Для самого SQL нужны права фактического владельца и возможность передать ownership целевой роли; server root или обычного GRANT ALL недостаточно для этого ([PostgreSQL 14 ALTER TABLE](https://www.postgresql.org/docs/14/sql-altertable.html)). При недостатке прав или неожиданной identity транзакция откатится целиком. diff --git a/docs/operations/dev-migration-role-university-owner.sql b/docs/operations/dev-migration-role-university-owner.sql index 9c7be270..a116c121 100644 --- a/docs/operations/dev-migration-role-university-owner.sql +++ b/docs/operations/dev-migration-role-university-owner.sql @@ -1,4 +1,4 @@ --- Одноразовая подготовка DEV администратором PostgreSQL, не часть deploy workflow. +-- Одноразовая подготовка DEV с правами владельца, не часть deploy workflow. -- Нет REASSIGN OWNED / ALTER ROLE / изменений владельцев БД или схем. BEGIN; SET LOCAL lock_timeout = '10s'; diff --git a/scripts/dev_ci_prepare_owner.py b/scripts/dev_ci_prepare_owner.py new file mode 100644 index 00000000..56ea113f --- /dev/null +++ b/scripts/dev_ci_prepare_owner.py @@ -0,0 +1,196 @@ +"""Одноразовая подготовка DEV на PostgreSQL 14 двумя существующими login.""" + +import getpass +import os +import signal +import sys + + +DATABASE = "procollab_db_dev" +RUNTIME = "procollab_api_dev" +MIGRATOR = "procollab_api" + + +class PreparationError(Exception): + pass + + +def prepare_owner(sql_text, migration_password): + if ( + os.environ.get("DATABASE_NAME") != DATABASE + or os.environ.get("DATABASE_USER") != RUNTIME + ): + raise PreparationError("Expected DEV database and runtime login") + if not migration_password: + raise PreparationError("Migration password is required") + import psycopg2 + + options = dict( + host=os.environ["DATABASE_HOST"], + port=os.environ.get("DATABASE_PORT") or "5432", + dbname=DATABASE, + sslmode="require", + connect_timeout=15, + ) + + def runtime_connection(): + connection = psycopg2.connect( + **options, user=RUNTIME, password=os.environ["DATABASE_PASSWORD"] + ) + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute("SET statement_timeout = '15s'") + return connection + + source = runtime_connection() + target = None + grant_attempted = False + initial_membership = None + try: + with source.cursor() as cursor: + cursor.execute( + "SELECT current_database(), current_user, session_user, current_setting('server_version_num')" + ) + database, user, session_user, version = cursor.fetchone() + if (database, user, session_user) != (DATABASE, RUNTIME, RUNTIME): + raise PreparationError("DEV runtime connection identity mismatch") + if int(version) // 10000 != 14: + raise PreparationError( + "This one-time self-role grant procedure is verified only on PostgreSQL 14" + ) + cursor.execute("SELECT pg_try_advisory_lock(180614, 20261018)") + if not cursor.fetchone()[0]: + raise PreparationError("Another DEV owner preparation is running") + cursor.execute("SELECT pg_has_role(%s, %s, 'MEMBER')", [MIGRATOR, RUNTIME]) + initial_membership = cursor.fetchone()[0] + cursor.execute("SELECT rolinherit FROM pg_roles WHERE rolname=%s", [MIGRATOR]) + row = cursor.fetchone() + if not row or not row[0]: + raise PreparationError("Expected existing INHERIT migration role") + cursor.execute( + "SELECT c.relname,pg_get_userbyid(c.relowner),pg_has_role(%s,c.relowner,'USAGE') " + "FROM pg_class c WHERE c.oid IN ('public.users_university'::regclass, " + "'public.users_university_id_seq'::regclass)", + [MIGRATOR], + ) + relations = cursor.fetchall() + if len(relations) != 2 or any( + owner not in (RUNTIME, MIGRATOR) for _, owner, _ in relations + ): + raise PreparationError("Unexpected DEV university ownership") + need_grant = not all(owned for _, _, owned in relations) + # Authenticate the known migration login before granting anything. + target = psycopg2.connect(**options, user=MIGRATOR, password=migration_password) + target.autocommit = True + with target.cursor() as cursor: + cursor.execute("SELECT current_database(), current_user, session_user") + if cursor.fetchone() != (DATABASE, MIGRATOR, MIGRATOR): + raise PreparationError("DEV migration connection identity mismatch") + if need_grant: + if initial_membership: + raise PreparationError( + "Existing membership does not provide expected ownership privileges" + ) + # PostgreSQL 14 permits session_user to grant membership in its own role. + # The direction is DEV role -> migrator; DEV never receives the PROD owner role. + grant_attempted = True + with source.cursor() as cursor: + cursor.execute("GRANT procollab_api_dev TO procollab_api") + with target.cursor() as cursor: + cursor.execute(sql_text) + cursor.execute( + "SELECT c.relname,pg_get_userbyid(c.relowner) FROM pg_class c " + "WHERE c.oid IN ('public.users_university'::regclass, " + "'public.users_university_id_seq'::regclass) ORDER BY c.relname" + ) + owners = cursor.fetchall() + if len(owners) != 2 or any(owner != MIGRATOR for _, owner in owners): + raise PreparationError("Final DEV ownership verification failed") + for function, relation, privileges in ( + ( + "has_table_privilege", + "public.users_university", + ("SELECT", "INSERT", "UPDATE", "DELETE"), + ), + ( + "has_sequence_privilege", + "public.users_university_id_seq", + ("USAGE", "SELECT", "UPDATE"), + ), + ): + for privilege in privileges: + cursor.execute( + "SELECT " + function + "(%s,%s,%s)", + [RUNTIME, relation, privilege], + ) + if not cursor.fetchone()[0]: + raise PreparationError( + "DEV runtime privilege verification failed" + ) + finally: + if target is not None: + target.close() # Also rolls back failed/uncommitted normalization before cleanup. + try: + if grant_attempted: + if source.closed: + source = runtime_connection() + with source.cursor() as cursor: + cursor.execute( + "SELECT pg_has_role(%s,%s,'MEMBER')", [MIGRATOR, RUNTIME] + ) + if cursor.fetchone()[0] != initial_membership: + cursor.execute("REVOKE procollab_api_dev FROM procollab_api") + cursor.execute( + "SELECT pg_has_role(%s,%s,'MEMBER')", [MIGRATOR, RUNTIME] + ) + if cursor.fetchone()[0] != initial_membership: + raise PreparationError( + "Temporary DEV membership did not return to its initial state" + ) + except Exception as error: + raise PreparationError( + "TEMPORARY MEMBERSHIP CLEANUP NOT VERIFIED. As procollab_api_dev run: " + "REVOKE procollab_api_dev FROM procollab_api;" + ) from error + finally: + source.close() + sys.stdout.write( + "DEV ownership prepared; runtime access verified; no new membership remains.\n" + ) + for name, owner in owners: + sys.stdout.write(f"{name} owner={owner}\n") + + +def main(): + password = None + try: + if len(sys.argv) != 2: + raise PreparationError( + "Pass the reviewed DEV normalization SQL as the only argument" + ) + if ( + os.environ.get("DATABASE_NAME") != DATABASE + or os.environ.get("DATABASE_USER") != RUNTIME + ): + raise PreparationError("Expected DEV database and runtime login") + password = getpass.getpass("Password for procollab_api (hidden): ") + prepare_owner(sys.argv[1], password) + except Exception as error: + message = str(error) + for secret in (password, os.environ.get("DATABASE_PASSWORD")): + if secret: + message = message.replace(secret, "[redacted]") + sys.stderr.write("STOP: " + message + "\n") + return 1 + return 0 + + +if __name__ == "__main__": + + def interrupted(signum, frame): + raise SystemExit("Interrupted; attempting temporary membership cleanup") + + for name in ("SIGTERM", "SIGHUP"): + if hasattr(signal, name): + signal.signal(getattr(signal, name), interrupted) + raise SystemExit(main()) diff --git a/scripts/tests/test_dev_ci_migrate.py b/scripts/tests/test_dev_ci_migrate.py index 8dbbb0e5..6587aa97 100644 --- a/scripts/tests/test_dev_ci_migrate.py +++ b/scripts/tests/test_dev_ci_migrate.py @@ -18,6 +18,11 @@ SPEC = importlib.util.spec_from_file_location("dev_ci_migrate", HELPER) MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) +PREP_SPEC = importlib.util.spec_from_file_location( + "dev_ci_prepare_owner", ROOT / "scripts/dev_ci_prepare_owner.py" +) +PREPARATION = importlib.util.module_from_spec(PREP_SPEC) +PREP_SPEC.loader.exec_module(PREPARATION) RUNTIME = "procollab_api_dev" MIGRATOR = "procollab_api" PASSWORD = "ci-only 'quoted' \"double\" $() `literal` \\ newline\nпароль" @@ -30,6 +35,34 @@ def payload(username=MIGRATOR, password=PASSWORD): class CredentialsTests(unittest.TestCase): + def test_owner_preparation_rejects_non_dev_before_connecting(self): + with mock.patch.dict( + os.environ, DATABASE_NAME="not_dev", DATABASE_USER=RUNTIME + ), mock.patch("psycopg2.connect") as connect: + with self.assertRaises(PREPARATION.PreparationError): + PREPARATION.prepare_owner("", PASSWORD) + connect.assert_not_called() + + def test_owner_preparation_error_redacts_both_passwords(self): + output = io.StringIO() + with mock.patch.dict( + os.environ, + DATABASE_NAME="procollab_db_dev", + DATABASE_USER=RUNTIME, + DATABASE_PASSWORD="runtime-secret", + ), mock.patch.object(sys, "argv", ["prepare", "SQL"]), mock.patch( + "getpass.getpass", return_value=PASSWORD + ), mock.patch.object( + PREPARATION, + "prepare_owner", + side_effect=RuntimeError(PASSWORD + " runtime-secret"), + ), mock.patch.object( + sys, "stderr", output + ): + self.assertEqual(PREPARATION.main(), 1) + self.assertNotIn(PASSWORD, output.getvalue()) + self.assertNotIn("runtime-secret", output.getvalue()) + def test_special_characters_round_trip_and_masked_output(self): with tempfile.TemporaryDirectory() as directory: target = Path(directory) / "github-output" @@ -451,6 +484,143 @@ def test_normalizer_refuses_non_dev_database(self): cursor.execute("ROLLBACK") self.assert_still_pending() + def seed_runtime_university(self): + with self.database.cursor() as cursor: + cursor.execute( + "CREATE TABLE users_university(id SERIAL PRIMARY KEY,name TEXT)" + ) + cursor.execute( + "INSERT INTO users_university(name) VALUES('preserve this row')" + ) + cursor.execute("ALTER TABLE users_university OWNER TO procollab_api_dev") + + def prepare_with_existing_logins(self, sql_text=None): + import psycopg2 + + connect = psycopg2.connect + + def fixture_connect(**kwargs): + self.assertEqual(kwargs["host"], "127.0.0.1") + self.assertEqual(str(kwargs["port"]), str(self.port)) + self.assertEqual(kwargs["dbname"], "procollab_db_dev") + kwargs["sslmode"] = "disable" # Disposable loopback fixture only. + return connect(**kwargs) + + if sql_text is None: + sql_text = ( + ROOT / "docs/operations/dev-migration-role-university-owner.sql" + ).read_text(encoding="utf-8") + with mock.patch.dict( + os.environ, + DATABASE_NAME="procollab_db_dev", + DATABASE_USER=RUNTIME, + DATABASE_PASSWORD=PASSWORD, + DATABASE_HOST="127.0.0.1", + DATABASE_PORT=str(self.port), + ), mock.patch("psycopg2.connect", side_effect=fixture_connect): + PREPARATION.prepare_owner(sql_text, PASSWORD) + + def require_postgres14(self): + if self.database.server_version // 10000 != 14: + self.skipTest("Self-role grant is a PostgreSQL 14 one-time procedure") + + def assert_no_temporary_membership(self): + with self.database.cursor() as cursor: + cursor.execute( + "SELECT pg_has_role('procollab_api','procollab_api_dev','MEMBER')" + ) + self.assertFalse(cursor.fetchone()[0]) + cursor.execute( + "SELECT pg_has_role('procollab_api_dev','procollab_api','MEMBER')" + ) + self.assertFalse(cursor.fetchone()[0]) + + def test_pg14_existing_logins_prepare_owner_then_remove_membership(self): + import psycopg2 + + self.require_postgres14() + self.seed_runtime_university() + self.prepare_with_existing_logins() + self.assert_no_temporary_membership() + with self.database.cursor() as cursor: + cursor.execute( + "SELECT pg_get_userbyid(relowner) FROM pg_class WHERE oid='users_university'::regclass" + ) + self.assertEqual(cursor.fetchone()[0], MIGRATOR) + with psycopg2.connect( + host="127.0.0.1", + port=self.port, + dbname="procollab_db_dev", + user=RUNTIME, + password=PASSWORD, + ) as runtime: + with runtime.cursor() as cursor: + cursor.execute("SELECT name FROM users_university") + self.assertEqual(cursor.fetchall(), [("preserve this row",)]) + cursor.execute( + "INSERT INTO users_university(name) VALUES('runtime still works') RETURNING id" + ) + self.assertEqual(cursor.fetchone()[0], 2) + self.prepare_with_existing_logins() # Idempotent; no new membership is necessary. + self.assert_no_temporary_membership() + result = self.run_helper() + self.assertEqual(result.returncode, 0, result.stderr) + + def test_pg14_injected_failure_rolls_back_owner_and_revokes_membership(self): + import psycopg2 + + self.require_postgres14() + self.seed_runtime_university() + with self.assertRaises(psycopg2.errors.DivisionByZero): + self.prepare_with_existing_logins( + "BEGIN; ALTER TABLE users_university OWNER TO procollab_api; SELECT 1/0; COMMIT;" + ) + self.assert_no_temporary_membership() + with self.database.cursor() as cursor: + cursor.execute( + "SELECT pg_get_userbyid(relowner) FROM pg_class WHERE oid='users_university'::regclass" + ) + self.assertEqual(cursor.fetchone()[0], RUNTIME) + cursor.execute("SELECT name FROM users_university") + self.assertEqual(cursor.fetchall(), [("preserve this row",)]) + + def test_pg14_preexisting_membership_is_preserved(self): + import psycopg2 + + self.require_postgres14() + self.seed_runtime_university() + runtime = psycopg2.connect( + host="127.0.0.1", + port=self.port, + dbname="procollab_db_dev", + user=RUNTIME, + password=PASSWORD, + ) + runtime.autocommit = True + try: + with runtime.cursor() as cursor: + cursor.execute("GRANT procollab_api_dev TO procollab_api") + self.prepare_with_existing_logins() + with self.database.cursor() as cursor: + cursor.execute( + "SELECT pg_has_role('procollab_api','procollab_api_dev','MEMBER')" + ) + self.assertTrue(cursor.fetchone()[0]) + finally: + with runtime.cursor() as cursor: + cursor.execute("REVOKE procollab_api_dev FROM procollab_api") + runtime.close() + + def test_owner_preparation_rejects_unverified_postgres_major_before_grant(self): + if self.database.server_version // 10000 == 14: + self.skipTest("This server supports the verified PostgreSQL 14 procedure") + self.seed_runtime_university() + with self.assertRaisesRegex( + PREPARATION.PreparationError, "verified only on PostgreSQL 14" + ): + self.prepare_with_existing_logins() + self.assert_no_temporary_membership() + if __name__ == "__main__": unittest.main()