server/ldap_auth.py: simple bind to ldaps://prime.local:636 as sAMAccountName@prime.local, nested-group membership via the LDAP_MATCHING_RULE_IN_CHAIN extensible match, and a selftest() that validates the DC certificate without binding so it can never contribute to a lockout. Verified against the live domain, not just reasoned about: selftest() to prime.local -> ok, "certificate validates" selftest() to 192.168.3.37 -> refused, untrusted (no IP SAN) empty / whitespace password -> empty_input, with Connection nulled out so any call to bind() would have raised missing CA file -> unconfigured, is_config_problem=True Tls.validate -> ssl.CERT_REQUIRED, explicit ca_certs_file Three things here are load-bearing and commented as such at the call site: - The empty-password guard runs BEFORE bind(). An LDAP simple bind with an empty password is an anonymous bind and it SUCCEEDS, so without the guard a blank password authenticates as whatever username was submitted. - No `version=` pin on Tls. An earlier draft of this file pinned PROTOCOL_TLSv1_2, which would have silently downgraded every connection from the TLS 1.3 these DCs actually negotiate. - Retries cover connect failures only. A rejected credential returns immediately, because every failed bind counts against the domain lockout policy and this endpoint must not become a way to lock people out of Windows. The trust anchor is server/certs/prime-ca-chain.pem - PRIME CONTROLS ROOT CA plus ISSUING CA 1, public certificates with no private key, checked in because they are public and long-lived (2051 / 2036). The system trust store is deliberately not used: it currently trusts five other self-signed CAs on this estate. LDAP_CA_FILE overrides the path for a mounted bundle. docker-compose.yml: the `outbound` network is no longer optional. Its comment said to detach it if you were not using the Micron asset picker; doing that now breaks every sign-in, since `internal` has no default gateway and therefore no route to prime.local:636. Not yet verified, and called out rather than assumed: the nested-group case needs a real group with a nested member, and the in-container `openssl s_client -CAfile` check needs the stack. Both are T10.1 done-when boxes still open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
136 lines
5.6 KiB
YAML
136 lines
5.6 KiB
YAML
services:
|
|
|
|
webserver:
|
|
build:
|
|
context: .
|
|
dockerfile: nginx/Dockerfile
|
|
container_name: nginx_webserver
|
|
volumes:
|
|
- nginx_logs:/var/log/nginx
|
|
restart: unless-stopped
|
|
depends_on:
|
|
api:
|
|
condition: service_started
|
|
networks:
|
|
- proxy # external — reachable by your reverse proxy / traefik
|
|
- internal # needs a path to the api container
|
|
|
|
api:
|
|
build: .
|
|
container_name: wp_api
|
|
environment:
|
|
# Preferred: the API builds its own connection string from these and
|
|
# encodes the password automatically (no manual URL-encoding needed).
|
|
POSTGRES_USER: ${POSTGRES_USER}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${POSTGRES_DB}
|
|
POSTGRES_HOST: db
|
|
# Optional full-URL override (must be URL-encoded if used).
|
|
DATABASE_URL: ${DATABASE_URL:-}
|
|
# Signs login session cookies. REQUIRED — compose fails fast if it's unset,
|
|
# and the API refuses to start in production without it (see server/auth.py).
|
|
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
|
|
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
|
# Optional — SMTP password for WP-assignment emails. Email is off by
|
|
# default and enabled from the Admin console; this is the only email
|
|
# secret and it is never stored in the DB. Leave unset until configured.
|
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
|
# D13 — domain authentication. REQUIRED: the suite stores no passwords and
|
|
# has no local fallback, so a wrong value here means nobody can sign in.
|
|
# Connect to the DOMAIN NAME, never a DC or an IP (SAN + DNS round-robin
|
|
# across six DCs). See server/ldap_auth.py and DEPLOYMENT.md.
|
|
LDAP_DOMAIN: ${LDAP_DOMAIN:-prime.local}
|
|
LDAP_HOST: ${LDAP_HOST:-prime.local}
|
|
# Trust anchor for the DC certificate — public CA certs, baked into the image
|
|
# at server/certs/. Override only to point at a mounted bundle.
|
|
LDAP_CA_FILE: ${LDAP_CA_FILE:-/app/server/certs/prime-ca-chain.pem}
|
|
# AD group required to sign in. Empty = any domain account. This is the
|
|
# initial value; the live one is set in the Admin console (T10.5).
|
|
LDAP_REQUIRED_GROUP: ${LDAP_REQUIRED_GROUP:-}
|
|
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
|
# which backs the asset picker in the work package creator. Leave unset and
|
|
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
|
# Use a db_datareader login: the app only ever SELECTs.
|
|
MICRON_DB_URL: ${MICRON_DB_URL:-}
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy # waits for postgres to accept connections
|
|
networks:
|
|
- internal
|
|
# Reaching the Micron database — and, since D13, the domain controllers —
|
|
# means leaving this compose project, and `internal` is deliberately
|
|
# egress-free. `outbound` is attached to the api container ONLY; the database
|
|
# and backup containers stay sealed.
|
|
#
|
|
# DO NOT DETACH THIS. It used to be optional ("detach it if you are not using
|
|
# the Micron asset picker"), but authentication now needs a route to
|
|
# prime.local:636. Without it every sign-in fails and there is no local
|
|
# password fallback to fall back to.
|
|
- outbound
|
|
|
|
db:
|
|
image: postgres:16-alpine
|
|
container_name: wp_db
|
|
environment:
|
|
POSTGRES_DB: ${POSTGRES_DB}
|
|
POSTGRES_USER: ${POSTGRES_USER}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
networks:
|
|
- internal
|
|
|
|
# Scheduled pg_dump backups. Writes gzipped, timestamped dumps to ./backups on
|
|
# the host (sync that folder offsite from the host — this container has no
|
|
# internet egress). See scripts/db-backup.sh and DEPLOYMENT.md § Backups.
|
|
backup:
|
|
build:
|
|
context: .
|
|
dockerfile: scripts/backup.Dockerfile # postgres client + openssl
|
|
container_name: wp_db_backup
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${POSTGRES_DB}
|
|
PGHOST: db
|
|
BACKUP_DIR: /backups
|
|
BACKUP_KEEP: ${BACKUP_KEEP:-14} # keep the newest N dumps
|
|
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} # 86400 = daily
|
|
# Set BACKUP_ENC_PASSPHRASE in .env to encrypt dumps at rest (AES-256).
|
|
# Required once the DB holds customer IP. Keep the passphrase off this host.
|
|
BACKUP_ENC_PASSPHRASE: ${BACKUP_ENC_PASSPHRASE:-}
|
|
volumes:
|
|
- ./scripts:/scripts:ro
|
|
- ./backups:/backups
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
networks:
|
|
- internal
|
|
|
|
volumes:
|
|
pgdata:
|
|
nginx_logs:
|
|
|
|
networks:
|
|
proxy:
|
|
name: proxy
|
|
external: true
|
|
internal:
|
|
internal: true # no route off the host for anything on this network alone
|
|
outbound:
|
|
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
|
|
# above removes the gateway entirely, which blocks not just the internet but
|
|
# the LAN and the VPN too — so the api container needs this second network to
|
|
# reach the domain controllers (LDAPS, D13) and the Micron asset database.
|
|
# Attached to `api` alone: `db` and `backup` remain on `internal` only and
|
|
# still have no way off the host. Required — see the note on the api service.
|
|
driver: bridge |