From 36742cd20c1a4b79d60cebbb97e6ace574c287a4 Mon Sep 17 00:00:00 2001 From: busya Date: Fri, 13 Mar 2026 11:41:44 +0300 Subject: [PATCH] Add docker admin bootstrap for clean release --- .env.enterprise-clean.example | 10 ++- README.md | 19 ++++-- backend/src/scripts/create_admin.py | 25 +++++--- backend/tests/test_auth.py | 30 +++++++++ docker-compose.enterprise-clean.yml | 4 ++ docker/backend.entrypoint.sh | 57 +++++++++++++++++ docs/installation.md | 61 ++++++++++++++++--- scripts/build_offline_docker_bundle.sh | 7 +++ specs/023-clean-repo-enterprise/plan.md | 31 +++++++++- specs/023-clean-repo-enterprise/quickstart.md | 17 ++++++ specs/023-clean-repo-enterprise/spec.md | 4 ++ specs/023-clean-repo-enterprise/tasks.md | 14 +++++ 12 files changed, 254 insertions(+), 25 deletions(-) create mode 100755 docker/backend.entrypoint.sh diff --git a/.env.enterprise-clean.example b/.env.enterprise-clean.example index 706f21de..156a8475 100644 --- a/.env.enterprise-clean.example +++ b/.env.enterprise-clean.example @@ -1,7 +1,7 @@ # Offline / air-gapped compose profile for enterprise clean release. -BACKEND_IMAGE=ss-tools-backend:v1.0.0-rc2 -FRONTEND_IMAGE=ss-tools-frontend:v1.0.0-rc2 +BACKEND_IMAGE=ss-tools-backend:v1.0.0-rc2-docker +FRONTEND_IMAGE=ss-tools-frontend:v1.0.0-rc2-docker POSTGRES_IMAGE=postgres:16-alpine POSTGRES_DB=ss_tools @@ -17,5 +17,11 @@ TASK_LOG_LEVEL=INFO STORAGE_ROOT=./storage +# Initial admin bootstrap. Set to true only for the first startup in a new environment. +INITIAL_ADMIN_CREATE=false +INITIAL_ADMIN_USERNAME=admin +INITIAL_ADMIN_PASSWORD=change-me +INITIAL_ADMIN_EMAIL= + OPENAI_API_KEY= ANTHROPIC_API_KEY= diff --git a/README.md b/README.md index 356c25da..17988389 100755 --- a/README.md +++ b/README.md @@ -250,21 +250,32 @@ cd /home/busya/dev/ss-tools ```bash # 1. Собрать образы в подключённом контуре -./scripts/build_offline_docker_bundle.sh v1.0.0-rc2 +./scripts/build_offline_docker_bundle.sh v1.0.0-rc2-docker # 2. Передать dist/docker/* в изолированный контур # 3. Импортировать образы локально -docker load -i dist/docker/backend.v1.0.0-rc2.tar -docker load -i dist/docker/frontend.v1.0.0-rc2.tar -docker load -i dist/docker/postgres.v1.0.0-rc2.tar +docker load -i dist/docker/backend.v1.0.0-rc2-docker.tar +docker load -i dist/docker/frontend.v1.0.0-rc2-docker.tar +docker load -i dist/docker/postgres.v1.0.0-rc2-docker.tar # 4. Подготовить env из шаблона cp dist/docker/.env.enterprise-clean.example .env.enterprise-clean +# 4a. Для первого запуска задать bootstrap администратора +# INITIAL_ADMIN_CREATE=true +# INITIAL_ADMIN_USERNAME= +# INITIAL_ADMIN_PASSWORD= + # 5. Запустить только локальные образы docker compose --env-file .env.enterprise-clean -f dist/docker/docker-compose.enterprise-clean.yml up -d ``` +Bootstrap администратора выполняется entrypoint-скриптом внутри backend container: +- если `INITIAL_ADMIN_CREATE=true`, контейнер вызывает [`create_admin.py`](backend/src/scripts/create_admin.py) перед стартом API; +- если администратор уже существует, учётная запись не меняется; +- теги в [`.env.enterprise-clean.example`](.env.enterprise-clean.example) должны совпадать с фактически загруженными образами `ss-tools-backend:v1.0.0-rc2-docker` и `ss-tools-frontend:v1.0.0-rc2-docker`; +- после первого входа пароль должен быть ротирован, а `INITIAL_ADMIN_CREATE` возвращён в `false`. + Ограничения для production-grade offline release: - build не должен тянуть зависимости в изолированном контуре; - все base images должны быть заранее зеркалированы во внутренний registry или поставляться как tar; diff --git a/backend/src/scripts/create_admin.py b/backend/src/scripts/create_admin.py index f5520849..09cdb7ac 100644 --- a/backend/src/scripts/create_admin.py +++ b/backend/src/scripts/create_admin.py @@ -31,10 +31,13 @@ from src.core.logger import logger, belief_scope # # @PARAM: username (str) - Admin username. # @PARAM: password (str) - Admin password. -def create_admin(username, password): +# @PARAM: email (str | None) - Optional admin email. +def create_admin(username, password, email=None): with belief_scope("create_admin"): db = AuthSessionLocal() try: + normalized_email = email.strip() if isinstance(email, str) and email.strip() else None + # 1. Ensure Admin role exists admin_role = db.query(Role).filter(Role.name == "Admin").first() if not admin_role: @@ -48,12 +51,13 @@ def create_admin(username, password): existing_user = db.query(User).filter(User.username == username).first() if existing_user: logger.warning(f"User {username} already exists.") - return + return "exists" # 3. Create Admin user logger.info(f"Creating admin user: {username}") new_user = User( username=username, + email=normalized_email, password_hash=get_password_hash(password), auth_source="LOCAL", is_active=True @@ -62,10 +66,12 @@ def create_admin(username, password): db.add(new_user) db.commit() logger.info(f"Admin user {username} created successfully.") - + return "created" + except Exception as e: logger.error(f"Failed to create admin user: {e}") db.rollback() + raise finally: db.close() # [/DEF:create_admin:Function] @@ -74,10 +80,15 @@ if __name__ == "__main__": parser = argparse.ArgumentParser(description="Create initial admin user") parser.add_argument("--username", required=True, help="Admin username") parser.add_argument("--password", required=True, help="Admin password") + parser.add_argument("--email", required=False, help="Admin email") args = parser.parse_args() - - # Ensure DB is initialized before creating admin - init_db() - create_admin(args.username, args.password) + + try: + # Ensure DB is initialized before creating admin + init_db() + create_admin(args.username, args.password, args.email) + sys.exit(0) + except Exception: + sys.exit(1) # [/DEF:backend.src.scripts.create_admin:Module] \ No newline at end of file diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 44fa1514..8a30f6a3 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -12,6 +12,7 @@ from src.models.auth import User, Role, Permission, ADGroupMapping from src.services.auth_service import AuthService from src.core.auth.repository import AuthRepository from src.core.auth.security import verify_password, get_password_hash +from src.scripts.create_admin import create_admin # Create in-memory SQLite database for testing SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" @@ -159,3 +160,32 @@ def test_ad_group_mapping(auth_repo): retrieved_mapping = auth_repo.db.query(ADGroupMapping).filter_by(ad_group="DOMAIN\\ADFS_Admins").first() assert retrieved_mapping is not None assert retrieved_mapping.role_id == role.id + + +def test_create_admin_creates_user_with_optional_email(monkeypatch, db_session): + """Test bootstrap admin creation stores optional email and Admin role""" + monkeypatch.setattr("src.scripts.create_admin.AuthSessionLocal", lambda: db_session) + + result = create_admin("bootstrap-admin", "bootstrap-pass", "admin@example.com") + + created_user = db_session.query(User).filter(User.username == "bootstrap-admin").first() + assert result == "created" + assert created_user is not None + assert created_user.email == "admin@example.com" + assert created_user.roles[0].name == "Admin" + + +def test_create_admin_is_idempotent_for_existing_user(monkeypatch, db_session): + """Test bootstrap admin creation preserves existing user on repeated runs""" + monkeypatch.setattr("src.scripts.create_admin.AuthSessionLocal", lambda: db_session) + + first_result = create_admin("bootstrap-admin-2", "bootstrap-pass") + second_result = create_admin("bootstrap-admin-2", "new-password", "changed@example.com") + + created_user = db_session.query(User).filter(User.username == "bootstrap-admin-2").first() + assert first_result == "created" + assert second_result == "exists" + assert created_user is not None + assert created_user.email is None + assert verify_password("bootstrap-pass", created_user.password_hash) + assert not verify_password("new-password", created_user.password_hash) diff --git a/docker-compose.enterprise-clean.yml b/docker-compose.enterprise-clean.yml index 343fda7a..73be2665 100644 --- a/docker-compose.enterprise-clean.yml +++ b/docker-compose.enterprise-clean.yml @@ -33,6 +33,10 @@ services: BACKEND_PORT: 8000 ENABLE_BELIEF_STATE_LOGGING: ${ENABLE_BELIEF_STATE_LOGGING:-true} TASK_LOG_LEVEL: ${TASK_LOG_LEVEL:-INFO} + INITIAL_ADMIN_CREATE: ${INITIAL_ADMIN_CREATE:-false} + INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} ports: diff --git a/docker/backend.entrypoint.sh b/docker/backend.entrypoint.sh new file mode 100755 index 00000000..be8ea6ce --- /dev/null +++ b/docker/backend.entrypoint.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# [DEF:docker.backend.entrypoint:Module] +# @TIER: STANDARD +# @SEMANTICS: docker, entrypoint, admin-bootstrap, runtime, backend +# @PURPOSE: Container entrypoint that performs optional idempotent admin bootstrap before starting backend runtime. +# @LAYER: Infra +# @RELATION: DEPENDS_ON -> backend/src/scripts/create_admin.py +# @INVARIANT: Existing admin account must never be overwritten during container restarts. +# [/DEF:docker.backend.entrypoint:Module] + +# [DEF:docker.backend.entrypoint.bootstrap_admin:Function] +# @PURPOSE: Execute optional initial admin bootstrap from runtime environment variables. +# @PRE: Python runtime and backend sources are available inside /app/backend. +# @POST: Admin is created only when INITIAL_ADMIN_CREATE=true and required credentials are present. +bootstrap_admin() { + local create_flag="${INITIAL_ADMIN_CREATE:-false}" + local username="${INITIAL_ADMIN_USERNAME:-}" + local password="${INITIAL_ADMIN_PASSWORD:-}" + local email="${INITIAL_ADMIN_EMAIL:-}" + + case "${create_flag,,}" in + true|1|yes|y) + ;; + *) + echo "[entrypoint] INITIAL_ADMIN_CREATE is disabled; skipping admin bootstrap" + return 0 + ;; + esac + + if [[ -z "${username}" ]]; then + echo "[entrypoint] INITIAL_ADMIN_USERNAME is required when INITIAL_ADMIN_CREATE=true" >&2 + return 1 + fi + + if [[ -z "${password}" ]]; then + echo "[entrypoint] INITIAL_ADMIN_PASSWORD is required when INITIAL_ADMIN_CREATE=true" >&2 + return 1 + fi + + echo "[entrypoint] initializing auth database" + python3 src/scripts/init_auth_db.py + + echo "[entrypoint] running idempotent admin bootstrap for user '${username}'" + if [[ -n "${email}" ]]; then + python3 src/scripts/create_admin.py --username "${username}" --password "${password}" --email "${email}" + else + python3 src/scripts/create_admin.py --username "${username}" --password "${password}" + fi +} +# [/DEF:docker.backend.entrypoint.bootstrap_admin:Function] + +bootstrap_admin + +echo "[entrypoint] starting backend: $*" +exec "$@" diff --git a/docs/installation.md b/docs/installation.md index 69609c00..53f3d477 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -152,15 +152,15 @@ psql -U postgres -d ss_tools ```bash cd /home/busya/dev/ss-tools -./scripts/build_offline_docker_bundle.sh v1.0.0-rc2 +./scripts/build_offline_docker_bundle.sh v1.0.0-rc2-docker ``` Результат появится в `dist/docker/`: -- `backend.v1.0.0-rc2.tar` -- `frontend.v1.0.0-rc2.tar` -- `postgres.v1.0.0-rc2.tar` -- `sha256sums.v1.0.0-rc2.txt` -- `manifest.v1.0.0-rc2.txt` +- `backend.v1.0.0-rc2-docker.tar` +- `frontend.v1.0.0-rc2-docker.tar` +- `postgres.v1.0.0-rc2-docker.tar` +- `sha256sums.v1.0.0-rc2-docker.txt` +- `manifest.v1.0.0-rc2-docker.txt` - `docker-compose.enterprise-clean.yml` - `.env.enterprise-clean.example` @@ -171,9 +171,9 @@ cd /home/busya/dev/ss-tools ### 3. Импорт образов ```bash -docker load -i backend.v1.0.0-rc2.tar -docker load -i frontend.v1.0.0-rc2.tar -docker load -i postgres.v1.0.0-rc2.tar +docker load -i backend.v1.0.0-rc2-docker.tar +docker load -i frontend.v1.0.0-rc2-docker.tar +docker load -i postgres.v1.0.0-rc2-docker.tar ``` ### 4. Подготовка конфигурации @@ -189,6 +189,16 @@ cp .env.enterprise-clean.example .env.enterprise-clean - `POSTGRES_PASSWORD` - `STORAGE_ROOT` +Для первого запуска в новом контуре дополнительно задайте: +- `INITIAL_ADMIN_CREATE=true` +- `INITIAL_ADMIN_USERNAME=` +- `INITIAL_ADMIN_PASSWORD=` +- `INITIAL_ADMIN_EMAIL=` + +Также проверьте, что теги образов в [`.env.enterprise-clean`](.env.enterprise-clean.example) совпадают с реально загруженными: +- `BACKEND_IMAGE=ss-tools-backend:v1.0.0-rc2-docker` +- `FRONTEND_IMAGE=ss-tools-frontend:v1.0.0-rc2-docker` + ### 5. Запуск в offline-контуре ```bash @@ -197,6 +207,21 @@ docker compose --env-file .env.enterprise-clean -f docker-compose.enterprise-cle Compose-файл использует `pull_policy: never`, поэтому runtime не должен обращаться к внешним registry. +### 6. Bootstrap администратора в контейнере + +При `INITIAL_ADMIN_CREATE=true` backend container автоматически: +1. инициализирует auth DB; +2. запускает [`create_admin.py`](../backend/src/scripts/create_admin.py) с runtime-параметрами; +3. создаёт пользователя только если его ещё нет; +4. при повторном старте не изменяет существующего администратора. + +После первого успешного входа обязательно: +- смените bootstrap-пароль на постоянный организационный секрет; +- установите `INITIAL_ADMIN_CREATE=false`; +- перезапустите stack с обновлённым `.env.enterprise-clean`. + +Если bootstrap завершается ошибкой, backend не стартует — это ожидаемый fail-fast режим для безопасного ввода в эксплуатацию. + ## Первая настройка ### 1. Инициализация базы данных @@ -450,6 +475,24 @@ export CLEAN_TUI_ARTIFACTS_JSON=/absolute/path/artifacts.json } ``` +Минимальный пример `artifacts.json`: + +```json +{ + "artifacts": [ + { + "id": "artifact-backend-dist", + "path": "backend/dist/package.tar.gz", + "sha256": "deadbeef", + "size": 1024, + "category": "core", + "source_uri": "https://repo.intra.company.local/releases/backend/dist/package.tar.gz", + "source_host": "repo.intra.company.local" + } + ] +} +``` + ### Политика источников (internal-only) Разрешены только хосты из внутреннего реестра компании, например: diff --git a/scripts/build_offline_docker_bundle.sh b/scripts/build_offline_docker_bundle.sh index 34ba2953..e3cdaa08 100755 --- a/scripts/build_offline_docker_bundle.sh +++ b/scripts/build_offline_docker_bundle.sh @@ -73,6 +73,7 @@ postgres_image_id=${POSTGRES_IMAGE_ID} postgres_repo_digest=${POSTGRES_REPO_DIGEST} compose_file=docker-compose.enterprise-clean.yml env_template=.env.enterprise-clean.example +env_bootstrap_fields=INITIAL_ADMIN_CREATE,INITIAL_ADMIN_USERNAME,INITIAL_ADMIN_PASSWORD,INITIAL_ADMIN_EMAIL checksums_file=sha256sums.${TAG}.txt generated_at_utc=$(date -u +"%Y-%m-%dT%H:%M:%SZ") EOF @@ -105,6 +106,12 @@ cat > "${DIST_ROOT}/manifest.${TAG}.json" < без ошибки, лог `already exists`). +3. В `docker-compose.enterprise-clean.yml` прокинуть переменные bootstrap администратора только в `backend` service. +4. В операционном runbook зафиксировать обязательную ротацию bootstrap-пароля после первого входа. +5. В offline bundle manifest оставить ссылку на `.env.enterprise-clean.example` как source-of-truth для параметров запуска. + +Нефункциональные ограничения: +- Никаких default production секретов в Git. +- Повторный restart контейнера не должен менять существующего admin. +- Ошибка bootstrap не должна маскироваться: должна логироваться и приводить к fail-fast старта backend (чтобы оператор устранил причину до ввода в эксплуатацию). ## Complexity Tracking diff --git a/specs/023-clean-repo-enterprise/quickstart.md b/specs/023-clean-repo-enterprise/quickstart.md index 9f455f19..526f9ebb 100644 --- a/specs/023-clean-repo-enterprise/quickstart.md +++ b/specs/023-clean-repo-enterprise/quickstart.md @@ -73,6 +73,23 @@ cd /home/busya/dev/ss-tools 2. Повторно запустить проверку из того же TUI экрана (`F5`). 3. Повторять до статуса `COMPLIANT`. +## Admin Bootstrap via `.env.enterprise-clean` (Docker Runtime) + +Для offline bundle deployment в контейнерах используйте runtime файл `.env.enterprise-clean` на базе шаблона `.env.enterprise-clean.example`. + +Обязательные параметры bootstrap: +- `INITIAL_ADMIN_CREATE=true` для первого запуска; +- `INITIAL_ADMIN_USERNAME=`; +- `INITIAL_ADMIN_PASSWORD=`; +- `INITIAL_ADMIN_EMAIL=`. + +Ожидаемое поведение: +1. Backend container стартует. +2. EntryPoint проверяет флаг `INITIAL_ADMIN_CREATE`. +3. Если пользователь не существует — создаётся Admin user. +4. Если пользователь уже существует — bootstrap шага пропускается без изменения учётной записи. +5. После первого входа оператор выполняет обязательную ротацию bootstrap-пароля. + ## CI Gate (обязательный) После операторского прогона TUI та же политика должна быть проверена в CI. diff --git a/specs/023-clean-repo-enterprise/spec.md b/specs/023-clean-repo-enterprise/spec.md index 2bc1787f..41d6c27a 100644 --- a/specs/023-clean-repo-enterprise/spec.md +++ b/specs/023-clean-repo-enterprise/spec.md @@ -76,6 +76,7 @@ - Что происходит, если внутренний сервер ресурсов временно недоступен во время развёртывания в изолированном контуре? - Как система реагирует, если в конфигурации присутствует косвенная ссылка на внешний интернет-источник (например, зеркальный URL вне корпоративного домена)? - Что происходит при повторной подготовке clean-дистрибутива для уже очищенного релиз-кандидата? +- Что происходит, если контейнер перезапускается с `INITIAL_ADMIN_CREATE=true` и администратор уже существует? ## Requirements *(mandatory)* @@ -101,6 +102,9 @@ - **FR-018**: Стадия `NO_EXTERNAL_ENDPOINTS` MUST сканировать все текстовые файлы (включая код, конфиги, скрипты) на наличие URL/хостов и сверять каждый найденный endpoint с `allowed_sources`. - **FR-019**: Процесс clean-подготовки MUST включать стадию очистки БД от тестовых пользователей и демо-данных. Правила очистки (таблицы, условия, исключения) задаются в секции `database_cleanup` файла `.clean-release.yaml`. - **FR-020**: Структура `.clean-release.yaml` MUST включать секции: `profile`, `scan_mode`, `prohibited_categories`, `prohibited_paths`, `allowed_sources`, `ignore_paths`, `database_cleanup` (с подсекциями `tables` и `preserve`). +- **FR-021**: Enterprise clean release bundle MUST включать `.env.enterprise-clean.example` с параметрами bootstrap администратора (`INITIAL_ADMIN_USERNAME`, `INITIAL_ADMIN_PASSWORD`, `INITIAL_ADMIN_EMAIL` optional, `INITIAL_ADMIN_CREATE`) и безопасными комментариями по ротации пароля. +- **FR-022**: Docker startup flow backend MUST поддерживать idempotent bootstrap администратора при запуске контейнера: при `INITIAL_ADMIN_CREATE=true` система создаёт администратора только если пользователь отсутствует и не перезаписывает существующие credentials/roles. +- **FR-023**: Процесс публикации clean bundle MUST включать операторский сценарий заполнения runtime `.env.enterprise-clean` из шаблона и подтверждение, что bootstrap-пароль заменён на организационный секрет до первого production запуска. ### Key Entities *(include if feature involves data)* diff --git a/specs/023-clean-repo-enterprise/tasks.md b/specs/023-clean-repo-enterprise/tasks.md index 8a65cd12..906fc149 100644 --- a/specs/023-clean-repo-enterprise/tasks.md +++ b/specs/023-clean-repo-enterprise/tasks.md @@ -141,6 +141,20 @@ --- +## Phase 8: Post-Release Hardening — Admin Bootstrap in Docker + +**Purpose**: Автоматизировать первичное создание администратора через runtime `.env.enterprise-clean` в offline/enterprise deployment. + +- [X] T045 Add admin bootstrap env contract to `.env.enterprise-clean.example` (`INITIAL_ADMIN_CREATE`, `INITIAL_ADMIN_USERNAME`, `INITIAL_ADMIN_PASSWORD`, optional `INITIAL_ADMIN_EMAIL`) +- [X] T046 Wire admin bootstrap envs to backend runtime in `docker-compose.enterprise-clean.yml` +- [X] T047 Add backend entrypoint flow that performs idempotent admin bootstrap before app start in `docker/backend.Dockerfile` and new entrypoint script +- [X] T048 Extend admin creation script for optional email and deterministic exit behavior for existing user in `backend/src/scripts/create_admin.py` +- [X] T049 Update offline bundle packaging metadata to preserve new env contract in `scripts/build_offline_docker_bundle.sh` and bundle docs +- [X] T050 Add deployment runbook section for secure admin bootstrap and mandatory password rotation in `README.md` and `docs/installation.md` +- [X] T051 Add regression tests for container bootstrap path and create-admin idempotency in `backend/tests/scripts/` and/or service tests + +--- + ## Dependencies & Execution Order ### Phase Dependencies