diff --git a/.gitignore b/.gitignore index 0d0970d..7728aae 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ coverage *.tsbuildinfo .pnpm-store docs/reports/ + +# Step-CA dev password — real file is gitignored; commit only the .example +infra/step-ca/dev-password diff --git a/docker-compose.federated.yml b/docker-compose.federated.yml index 60fa88b..e614c96 100644 --- a/docker-compose.federated.yml +++ b/docker-compose.federated.yml @@ -55,6 +55,63 @@ services: timeout: 3s retries: 5 + # --------------------------------------------------------------------------- + # Step-CA — Mosaic Federation internal certificate authority + # + # Image: pinned to 0.27.4 (latest stable as of late 2025). + # `latest` is forbidden per Mosaic image policy (immutable tag required for + # reproducible deployments and digest-first promotion in CI). + # + # Profile: `federated` — this service must not start in non-federated dev. + # + # Password: + # Dev: bind-mount ./infra/step-ca/dev-password (gitignored; copy from + # ./infra/step-ca/dev-password.example and customise locally). + # Prod: replace the bind-mount with a Docker secret: + # secrets: + # ca_password: + # external: true + # and reference it as `/run/secrets/ca_password` (same path the + # init script already uses). + # + # Provisioner: "mosaic-fed" (consumed by apps/gateway/src/federation/ca.service.ts) + # --------------------------------------------------------------------------- + step-ca: + image: smallstep/step-ca:0.27.4 + profiles: [federated] + ports: + - '${STEP_CA_HOST_PORT:-9000}:9000' + volumes: + - step_ca_data:/home/step + # init script — executed as the container entrypoint + - ./infra/step-ca/init.sh:/usr/local/bin/mosaic-step-ca-init.sh:ro + # X.509 template skeleton (wired in M2-04) + - ./infra/step-ca/templates:/etc/step-ca-templates:ro + # Dev password file — GITIGNORED; copy from dev-password.example + # In production, replace this with a Docker secret (see comment above). + - ./infra/step-ca/dev-password:/run/secrets/ca_password:ro + entrypoint: ['/bin/sh', '/usr/local/bin/mosaic-step-ca-init.sh'] + healthcheck: + # The healthcheck requires the root cert to exist, which is only true + # after init.sh has completed on first boot. start_period gives init + # time to finish before Docker starts counting retries. + test: + [ + 'CMD', + 'step', + 'ca', + 'health', + '--ca-url', + 'https://localhost:9000', + '--root', + '/home/step/certs/root_ca.crt', + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + volumes: pg_federated_data: valkey_federated_data: + step_ca_data: diff --git a/infra/step-ca/dev-password.example b/infra/step-ca/dev-password.example new file mode 100644 index 0000000..be30142 --- /dev/null +++ b/infra/step-ca/dev-password.example @@ -0,0 +1 @@ +dev-only-step-ca-password-do-not-use-in-production diff --git a/infra/step-ca/init.sh b/infra/step-ca/init.sh new file mode 100755 index 0000000..c363ffc --- /dev/null +++ b/infra/step-ca/init.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# infra/step-ca/init.sh +# +# Idempotent first-boot initialiser for the Mosaic Federation CA. +# +# On the first run (no /home/step/config/ca.json present) this script: +# 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed". +# 2. Writes the CA configuration to the persistent volume at /home/step. +# +# On subsequent runs (config already exists) this script skips init and +# starts the CA directly. +# +# The provisioner name "mosaic-fed" is consumed by: +# apps/gateway/src/federation/ca.service.ts (added in M2-04) +# +# Password source: +# Dev: mounted from ./infra/step-ca/dev-password via bind mount. +# Prod: mounted from a Docker secret at /run/secrets/ca_password. +# +# OID template: +# infra/step-ca/templates/federation.tpl is copied into the CA config +# directory so the JWK provisioner can reference it. The template +# skeleton is wired in M2-04 when the CA service lands the SAN-bearing +# CSR work. + +set -e + +CA_CONFIG="/home/step/config/ca.json" +PASSWORD_FILE="/run/secrets/ca_password" + +if [ ! -f "${CA_CONFIG}" ]; then + echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..." + + step ca init \ + --name "Mosaic Federation CA" \ + --dns "localhost" \ + --dns "step-ca" \ + --address ":9000" \ + --provisioner "mosaic-fed" \ + --password-file "${PASSWORD_FILE}" \ + --provisioner-password-file "${PASSWORD_FILE}" \ + --no-db + + echo "[step-ca init] CA initialised." + + # Copy the X.509 template into the Step-CA config directory so the + # provisioner can reference it in M2-04. + if [ -f "/etc/step-ca-templates/federation.tpl" ]; then + mkdir -p /home/step/templates + cp /etc/step-ca-templates/federation.tpl /home/step/templates/federation.tpl + echo "[step-ca init] Federation X.509 template copied to /home/step/templates/." + fi + + echo "[step-ca init] Startup complete." +else + echo "[step-ca init] Config already exists — skipping init." +fi + +echo "[step-ca init] Starting Step-CA on :9000..." +exec step-ca /home/step/config/ca.json --password-file "${PASSWORD_FILE}" diff --git a/infra/step-ca/templates/federation.tpl b/infra/step-ca/templates/federation.tpl new file mode 100644 index 0000000..0e2f132 --- /dev/null +++ b/infra/step-ca/templates/federation.tpl @@ -0,0 +1,48 @@ +{ + "subject": {{ toJson .Subject }}, + "sans": {{ toJson .SANs }}, + + {{- /* + Mosaic Federation X.509 Certificate Template + ============================================ + This template is used by the "mosaic-fed" JWK provisioner to sign + federation client certificates. + + Custom OID extensions (per PRD §6): + 1.3.6.1.4.1.99999.1 — mosaic.federation.grantId (UUID string) + 1.3.6.1.4.1.99999.2 — mosaic.federation.subjectUserId (UUID string) + + TODO (M2-04): Wire actual OID extensions below once the CA service + (apps/gateway/src/federation/ca.service.ts) lands the SAN-bearing CSR + work and the template can be exercised end-to-end. + + Step-CA template reference: + https://smallstep.com/docs/step-ca/templates + + Expected final shape of the extensions block (placeholder — not yet + activated): + + "extensions": [ + { + "id": "1.3.6.1.4.1.99999.1", + "critical": false, + "value": {{ toJson (first .Token.mosaic_grant_id) }} + }, + { + "id": "1.3.6.1.4.1.99999.2", + "critical": false, + "value": {{ toJson (first .Token.mosaic_subject_user_id) }} + } + ], + + The provisioner must pass these values in the ACME/JWK token payload + (token claims `mosaic_grant_id` and `mosaic_subject_user_id`) when + submitting the CSR. M2-04 owns that work. + */ -}} + + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["clientAuth"], + "basicConstraints": { + "isCA": false + } +}