SECURITY - PLATFORM: add staging hardening baseline
This commit is contained in:
parent
95072586b9
commit
0e462012da
|
|
@ -33,7 +33,31 @@ Reverse proxy обязан прокидывать:
|
||||||
|
|
||||||
## Runtime topology
|
## 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 `server` и `worker` используют отдельный Postgres volume/network;
|
||||||
- Authentik worker не монтирует `/var/run/docker.sock`;
|
- Authentik worker не монтирует `/var/run/docker.sock`;
|
||||||
|
|
@ -122,4 +146,3 @@ Rotation policy до допуска внешних пользователей:
|
||||||
8. Audit сохраняет admin actions, approve/reject и hard delete.
|
8. Audit сохраняет admin actions, approve/reject и hard delete.
|
||||||
9. Повторный accept уже принятого invite отклоняется.
|
9. Повторный accept уже принятого invite отклоняется.
|
||||||
10. Прямые backend/internal endpoints без token возвращают `401`.
|
10. Прямые backend/internal endpoints без token возвращают `401`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue