diff --git a/docs/STAGING_SECURITY_PLAN.md b/docs/STAGING_SECURITY_PLAN.md index 048517a..287634e 100644 --- a/docs/STAGING_SECURITY_PLAN.md +++ b/docs/STAGING_SECURITY_PLAN.md @@ -33,7 +33,31 @@ Reverse proxy обязан прокидывать: ## Runtime topology -Staging можно собрать compose-файлом или эквивалентным deployment unit, но правила одинаковые: +Staging baseline добавлен как пример: + +```text +platform/infra/docker-compose.staging.example.yml +platform/infra/reverse-proxy/Caddyfile.staging +platform/infra/.env.staging.example +``` + +Запускать его нужно только после копирования `.env.staging.example` в ignored `.env.staging` и замены всех placeholder secrets: + +```bash +cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform/infra +./scripts/check-staging-env.sh .env.staging +docker compose --env-file .env.staging -f docker-compose.staging.example.yml config +docker compose --env-file .env.staging -f docker-compose.staging.example.yml up -d +``` + +Можно проверить example compose без реальных secret так: + +```bash +NODEDC_STAGING_ENV_FILE=.env.staging.example \ + docker compose --env-file .env.staging.example -f docker-compose.staging.example.yml config +``` + +Runtime правила: - Authentik `server` и `worker` используют отдельный Postgres volume/network; - Authentik worker не монтирует `/var/run/docker.sock`; @@ -122,4 +146,3 @@ Rotation policy до допуска внешних пользователей: 8. Audit сохраняет admin actions, approve/reject и hard delete. 9. Повторный accept уже принятого invite отклоняется. 10. Прямые backend/internal endpoints без token возвращают `401`. - diff --git a/infra/.env.staging.example b/infra/.env.staging.example new file mode 100644 index 0000000..c621c79 --- /dev/null +++ b/infra/.env.staging.example @@ -0,0 +1,50 @@ +# staging domains +AUTH_DOMAIN=auth.staging.nodedc.example +LAUNCHER_DOMAIN=launcher.staging.nodedc.example +TASK_DOMAIN=task.staging.nodedc.example + +# edge proxy +ACME_EMAIL=admin@nodedc.example +PLATFORM_HTTP_PORT=80 +PLATFORM_HTTPS_PORT=443 +PLATFORM_PROXY_IMAGE=caddy:2-alpine +STAGING_LAUNCHER_UPSTREAM=launcher:5173 +STAGING_TASK_MANAGER_UPSTREAM=task-manager-proxy:80 + +# authentik image +AUTHENTIK_IMAGE=ghcr.io/goauthentik/server +AUTHENTIK_TAG=2026.2.2 + +# authentik database +PG_DB=authentik +PG_USER=authentik +PG_PASS=replace-with-random-staging-secret + +# authentik +AUTHENTIK_SECRET_KEY=replace-with-random-staging-secret +AUTHENTIK_ERROR_REPORTING__ENABLED=false +AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=replace-with-reverse-proxy-subnet + +# launcher oidc +LAUNCHER_OIDC_ISSUER=https://auth.staging.nodedc.example/application/o/launcher/ +LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher +LAUNCHER_OIDC_CLIENT_SECRET=replace-with-random-staging-secret +LAUNCHER_OIDC_REDIRECT_URI=https://launcher.staging.nodedc.example/auth/callback +LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI=https://launcher.staging.nodedc.example/auth/logged-out + +# plane oidc +PLANE_OIDC_ISSUER=https://auth.staging.nodedc.example/application/o/task-manager/ +PLANE_OIDC_CLIENT_ID=nodedc-task-manager +PLANE_OIDC_CLIENT_SECRET=replace-with-random-staging-secret +PLANE_OIDC_REDIRECT_URI=https://task.staging.nodedc.example/auth/oidc/callback + +# security +SESSION_SECRET=replace-with-random-staging-secret +NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-random-staging-secret +COOKIE_DOMAIN=.staging.nodedc.example +COOKIE_SECURE=true + +# tasker downstream security +PLANE_NODEDC_ACCESS_ENFORCEMENT=1 +PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED=1 +PLANE_NODEDC_ACCESS_TOKEN=replace-with-same-value-as-NODEDC_INTERNAL_ACCESS_TOKEN diff --git a/infra/docker-compose.staging.example.yml b/infra/docker-compose.staging.example.yml new file mode 100644 index 0000000..35e0eb9 --- /dev/null +++ b/infra/docker-compose.staging.example.yml @@ -0,0 +1,108 @@ +name: nodedc-platform-staging + +services: + reverse-proxy: + image: ${PLATFORM_PROXY_IMAGE:-caddy:2-alpine} + restart: unless-stopped + env_file: + - path: ${NODEDC_STAGING_ENV_FILE:-.env.staging} + required: true + ports: + - "${PLATFORM_HTTP_PORT:-80}:80" + - "${PLATFORM_HTTPS_PORT:-443}:443" + volumes: + - ./reverse-proxy/Caddyfile.staging:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + authentik-server: + condition: service_started + networks: + - edge + - identity + + postgresql-authentik: + image: docker.io/library/postgres:16-alpine + restart: unless-stopped + env_file: + - path: ${NODEDC_STAGING_ENV_FILE:-.env.staging} + required: true + environment: + POSTGRES_DB: ${PG_DB:-authentik} + POSTGRES_PASSWORD: ${PG_PASS:?database password required} + POSTGRES_USER: ${PG_USER:-authentik} + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + volumes: + - authentik-database:/var/lib/postgresql/data + networks: + - identity + + authentik-server: + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2} + command: server + restart: unless-stopped + env_file: + - path: ${NODEDC_STAGING_ENV_FILE:-.env.staging} + required: true + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false} + AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: ${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:?trusted proxy CIDR required} + depends_on: + postgresql-authentik: + condition: service_healthy + expose: + - "9000" + - "9443" + shm_size: 512mb + volumes: + - authentik-data:/data + - ./authentik/custom-templates:/templates:ro + networks: + - identity + + authentik-worker: + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2} + command: worker + restart: unless-stopped + env_file: + - path: ${NODEDC_STAGING_ENV_FILE:-.env.staging} + required: true + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false} + depends_on: + postgresql-authentik: + condition: service_healthy + shm_size: 512mb + volumes: + - authentik-data:/data + - authentik-certs:/certs + - ./authentik/custom-templates:/templates:ro + networks: + - identity + +networks: + edge: + identity: + internal: true + +volumes: + authentik-database: + authentik-data: + authentik-certs: + caddy-data: + caddy-config: diff --git a/infra/reverse-proxy/Caddyfile.staging b/infra/reverse-proxy/Caddyfile.staging new file mode 100644 index 0000000..ece6046 --- /dev/null +++ b/infra/reverse-proxy/Caddyfile.staging @@ -0,0 +1,57 @@ +{ + email {$ACME_EMAIL} +} + +http://{$AUTH_DOMAIN} { + redir https://{host}{uri} permanent +} + +http://{$LAUNCHER_DOMAIN} { + redir https://{host}{uri} permanent +} + +http://{$TASK_DOMAIN} { + redir https://{host}{uri} permanent +} + +{$AUTH_DOMAIN} { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + } + + @auth_root path / + redir @auth_root https://{$LAUNCHER_DOMAIN}/ 302 + + @auth_user_dashboard path /if/user /if/user/* + redir @auth_user_dashboard https://{$LAUNCHER_DOMAIN}/ 302 + + reverse_proxy authentik-server:9000 { + header_up Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-For {remote_host} + } +} + +{$LAUNCHER_DOMAIN} { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + } + + reverse_proxy {$STAGING_LAUNCHER_UPSTREAM} { + header_up Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-For {remote_host} + } +} + +{$TASK_DOMAIN} { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + } + + reverse_proxy {$STAGING_TASK_MANAGER_UPSTREAM} { + header_up Host {host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-For {remote_host} + } +} diff --git a/infra/scripts/check-staging-env.sh b/infra/scripts/check-staging-env.sh new file mode 100755 index 0000000..063f6df --- /dev/null +++ b/infra/scripts/check-staging-env.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${1:-"$ROOT_DIR/.env.staging"}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing staging env file: $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +failures=0 + +fail() { + echo "FAIL: $*" >&2 + failures=$((failures + 1)) +} + +require_value() { + local name="$1" + local value="${!name:-}" + + if [[ -z "$value" ]]; then + fail "$name is required" + fi +} + +require_secret() { + local name="$1" + local value="${!name:-}" + + require_value "$name" + + if [[ "$value" =~ change-me|local-dev|replace-with|example ]]; then + fail "$name uses a placeholder/dev value" + fi + + if [[ ${#value} -lt 32 ]]; then + fail "$name must be at least 32 characters" + fi +} + +require_https_url() { + local name="$1" + local value="${!name:-}" + + require_value "$name" + + if [[ "$value" != https://* ]]; then + fail "$name must use https://" + fi +} + +require_staging_domain() { + local name="$1" + local value="${!name:-}" + + require_value "$name" + + if [[ "$value" == *.local.nodedc || "$value" == "localhost" || "$value" == 127.* ]]; then + fail "$name must not use local/dev domain" + fi +} + +require_value AUTH_DOMAIN +require_value LAUNCHER_DOMAIN +require_value TASK_DOMAIN +require_value STAGING_LAUNCHER_UPSTREAM +require_value STAGING_TASK_MANAGER_UPSTREAM +require_value AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS + +require_staging_domain AUTH_DOMAIN +require_staging_domain LAUNCHER_DOMAIN +require_staging_domain TASK_DOMAIN + +require_https_url LAUNCHER_OIDC_ISSUER +require_https_url LAUNCHER_OIDC_REDIRECT_URI +require_https_url LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI +require_https_url PLANE_OIDC_ISSUER +require_https_url PLANE_OIDC_REDIRECT_URI + +require_secret PG_PASS +require_secret AUTHENTIK_SECRET_KEY +require_secret SESSION_SECRET +require_secret NODEDC_INTERNAL_ACCESS_TOKEN +require_secret LAUNCHER_OIDC_CLIENT_SECRET +require_secret PLANE_OIDC_CLIENT_SECRET + +if [[ "${COOKIE_SECURE:-}" != "true" ]]; then + fail "COOKIE_SECURE must be true" +fi + +if [[ "${COOKIE_DOMAIN:-}" == *.local.nodedc || "${COOKIE_DOMAIN:-}" == "localhost" ]]; then + fail "COOKIE_DOMAIN must not use local/dev domain" +fi + +if [[ "${NODEDC_INTERNAL_ACCESS_TOKEN:-}" == "${LAUNCHER_OIDC_CLIENT_SECRET:-}" ]]; then + fail "NODEDC_INTERNAL_ACCESS_TOKEN must differ from LAUNCHER_OIDC_CLIENT_SECRET" +fi + +if [[ "${NODEDC_INTERNAL_ACCESS_TOKEN:-}" == "${PLANE_OIDC_CLIENT_SECRET:-}" ]]; then + fail "NODEDC_INTERNAL_ACCESS_TOKEN must differ from PLANE_OIDC_CLIENT_SECRET" +fi + +if [[ "${LAUNCHER_OIDC_CLIENT_SECRET:-}" == "${PLANE_OIDC_CLIENT_SECRET:-}" ]]; then + fail "Launcher and Tasker OIDC client secrets must differ" +fi + +if [[ "${PLANE_NODEDC_ACCESS_TOKEN:-}" != "${NODEDC_INTERNAL_ACCESS_TOKEN:-}" ]]; then + fail "PLANE_NODEDC_ACCESS_TOKEN must match NODEDC_INTERNAL_ACCESS_TOKEN" +fi + +if [[ "${PLANE_NODEDC_ACCESS_ENFORCEMENT:-}" != "1" ]]; then + fail "PLANE_NODEDC_ACCESS_ENFORCEMENT must be 1" +fi + +if [[ "${PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED:-}" != "1" ]]; then + fail "PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED must be 1" +fi + +case "${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:-}" in + *"0.0.0.0/0"*|*"::/0"*|*"10.0.0.0/8"*|*"172.16.0.0/12"*|*"192.168.0.0/16"*|*"127.0.0.0/8"*) + fail "AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS must be limited to the actual reverse-proxy/ingress subnet" + ;; +esac + +if [[ $failures -gt 0 ]]; then + echo "Staging env check failed with $failures issue(s)." >&2 + exit 1 +fi + +echo "Staging env check passed: $ENV_FILE"