From 25cbcde79441ef9c694da892c456c15a3dfa7c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Tue, 15 Sep 2026 17:21:13 +0200 Subject: [PATCH 01/47] chore(deploy): implement helm chart with tests and deployment documentation --- .github/actions/setup-helm/action.yml | 29 + .github/workflows/ci.yml | 160 ++++- .prettierignore | 1 + CLAUDE.md | 2 + README.md | 16 +- deploy/argocd/application.example.yaml | 46 ++ deploy/argocd/appproject.example.yaml | 49 ++ .../__snapshots__/encryption.test.ts.snap | 522 ++++++++++++++++ deploy/helm/encryption.test.ts | 227 +++++++ deploy/helm/encryption/.helmignore | 5 + deploy/helm/encryption/Chart.yaml | 9 + deploy/helm/encryption/README.md | 88 +++ .../ci/existing-secrets-values.yaml | 35 ++ deploy/helm/encryption/ci/full-values.yaml | 68 +++ deploy/helm/encryption/templates/NOTES.txt | 25 + deploy/helm/encryption/templates/_helpers.tpl | 243 ++++++++ .../helm/encryption/templates/deployment.yaml | 119 ++++ deploy/helm/encryption/templates/ingress.yaml | 40 ++ .../encryption/templates/migration-job.yaml | 134 +++++ .../encryption/templates/networkpolicy.yaml | 34 ++ deploy/helm/encryption/templates/pdb.yaml | 14 + deploy/helm/encryption/templates/secret.yaml | 26 + deploy/helm/encryption/templates/service.yaml | 20 + .../encryption/templates/serviceaccount.yaml | 14 + .../encryption/templates/tests/health.yaml | 60 ++ deploy/helm/encryption/values.schema.json | 555 ++++++++++++++++++ deploy/helm/encryption/values.yaml | 227 +++++++ deploy/helm/smoke-k3d.sh | 143 +++++ deploy/helmfile/env/prod/values.yaml.gotmpl | 64 ++ deploy/helmfile/helmfile.yaml.gotmpl | 36 ++ package-lock.json | 3 +- package.json | 9 +- renovate.json | 16 + src/server/env-schema.ts | 75 +++ src/server/env.ts | 73 +-- vitest.config.ts | 20 +- 36 files changed, 3124 insertions(+), 83 deletions(-) create mode 100644 .github/actions/setup-helm/action.yml create mode 100644 deploy/argocd/application.example.yaml create mode 100644 deploy/argocd/appproject.example.yaml create mode 100644 deploy/helm/__snapshots__/encryption.test.ts.snap create mode 100644 deploy/helm/encryption.test.ts create mode 100644 deploy/helm/encryption/.helmignore create mode 100644 deploy/helm/encryption/Chart.yaml create mode 100644 deploy/helm/encryption/README.md create mode 100644 deploy/helm/encryption/ci/existing-secrets-values.yaml create mode 100644 deploy/helm/encryption/ci/full-values.yaml create mode 100644 deploy/helm/encryption/templates/NOTES.txt create mode 100644 deploy/helm/encryption/templates/_helpers.tpl create mode 100644 deploy/helm/encryption/templates/deployment.yaml create mode 100644 deploy/helm/encryption/templates/ingress.yaml create mode 100644 deploy/helm/encryption/templates/migration-job.yaml create mode 100644 deploy/helm/encryption/templates/networkpolicy.yaml create mode 100644 deploy/helm/encryption/templates/pdb.yaml create mode 100644 deploy/helm/encryption/templates/secret.yaml create mode 100644 deploy/helm/encryption/templates/service.yaml create mode 100644 deploy/helm/encryption/templates/serviceaccount.yaml create mode 100644 deploy/helm/encryption/templates/tests/health.yaml create mode 100644 deploy/helm/encryption/values.schema.json create mode 100644 deploy/helm/encryption/values.yaml create mode 100755 deploy/helm/smoke-k3d.sh create mode 100644 deploy/helmfile/env/prod/values.yaml.gotmpl create mode 100644 deploy/helmfile/helmfile.yaml.gotmpl create mode 100644 src/server/env-schema.ts diff --git a/.github/actions/setup-helm/action.yml b/.github/actions/setup-helm/action.yml new file mode 100644 index 0000000..6f5e561 --- /dev/null +++ b/.github/actions/setup-helm/action.yml @@ -0,0 +1,29 @@ +name: Setup Helm +description: Helm and kubeconform for the chart tests and the chart release. + +runs: + using: composite + steps: + - name: Install Helm and kubeconform + shell: bash + env: + HELM_VERSION: v3.19.0 + HELM_SHA256: a7f81ce08007091b86d8bd696eb4d86b8d0f2e1b9f6c714be62f82f96a594496 + KUBECONFORM_VERSION: v0.7.0 + KUBECONFORM_SHA256: c31518ddd122663b3f3aa874cfe8178cb0988de944f29c74a0b9260920d115d3 + run: | # zizmor: ignore[github-env] the path written is a constant, never derived from an input + set -euo pipefail + mkdir -p "${HOME}/.local/bin" + + curl -sSfL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" -o /tmp/helm.tgz + echo "${HELM_SHA256} /tmp/helm.tgz" | sha256sum -c - + tar -xzf /tmp/helm.tgz -C /tmp linux-amd64/helm + mv /tmp/linux-amd64/helm "${HOME}/.local/bin/helm" + + curl -sSfL "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" -o /tmp/kubeconform.tgz + echo "${KUBECONFORM_SHA256} /tmp/kubeconform.tgz" | sha256sum -c - + tar -xzf /tmp/kubeconform.tgz -C "${HOME}/.local/bin" kubeconform + + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + "${HOME}/.local/bin/helm" version --short + "${HOME}/.local/bin/kubeconform" -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04a4afb..406f439 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,8 @@ on: branches: - main tags: - - 'v*' + - 'v*' # the application: image build, scan, push, attestation + - 'chart/v*' # the Helm chart alone: package, push, attestation (never mixed with the application jobs, see each job's `if`) pull_request: permissions: # Default permissions @@ -19,6 +20,7 @@ concurrency: env: CONTAINER_REGISTRY: docker.io CONTAINER_IMAGE: lasuite/encryption + CHART_IMAGE: lasuite/encryption-chart # its own repository: chart versions must not collide with image tags NODE_OPTIONS: --max-old-space-size=4096 NODE_VERSION: 24.19.0 NPM_VERSION: 12.0.2 @@ -27,6 +29,7 @@ jobs: # TODO: add egress filter when GitHub releases it end of 2026, and maybe scopes on events that trigger the pipeline supply-chain: name: Supply chain + if: ${{ !startsWith(github.ref, 'refs/tags/chart/') }} # a chart tag runs only the chart jobs runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -61,6 +64,7 @@ jobs: verify: name: Verify + if: ${{ !startsWith(github.ref, 'refs/tags/chart/') }} # a chart tag runs only the chart jobs runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -104,6 +108,7 @@ jobs: test: name: Unit tests + if: ${{ !startsWith(github.ref, 'refs/tags/chart/') }} # a chart tag runs only the chart jobs runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -128,6 +133,7 @@ jobs: e2e: name: Story tests + if: ${{ !startsWith(github.ref, 'refs/tags/chart/') }} # a chart tag runs only the chart jobs runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -173,7 +179,7 @@ jobs: build: name: Build # when releasing the build will be done with docker, so only target pull requests here - if: ${{ github.event.act || github.event_name == 'pull_request' }} + if: ${{ github.event.act || (github.event_name == 'pull_request' && !startsWith(github.ref, 'refs/tags/chart/')) }} runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -196,10 +202,79 @@ jobs: - name: Build run: npm run build + helm: + name: Helm chart + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} # an application tag never touches the chart + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + node-version: ${{ env.NODE_VERSION }} + npm-version: ${{ env.NPM_VERSION }} + + - name: Install Helm and kubeconform + uses: ./.github/actions/setup-helm + + - name: Test the chart # lint, values schema, Kubernetes API schemas, rendered objects, snapshot + run: npm run test:helm + + changes: + name: Changes since the last successful run + # A branch build republishes `main` (image) and `0.0.0-main` (chart) only when their part changed + # since the last run of this workflow that succeeded on the branch. Not since the previous push: + # a run whose release failed or was cancelled is not a base, so the next push publishes again. + # `.github` counts for both, a workflow change can change what gets published. + # A tag, "act", a pull request, no successful run yet, or its commit gone (force-push) publish anyway. + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read # to find the last successful run + outputs: + app: ${{ steps.diff.outputs.app }} + chart: ${{ steps.diff.outputs.chart }} + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + fetch-depth: 0 # the last successful run's commit has to be there to diff against it + + - name: Compare with the last successful run + id: diff + env: + GH_TOKEN: ${{ github.token }} + run: | + BASE="" + if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF_TYPE}" == "branch" ]]; then + WORKFLOW_FILE="$(basename "${GITHUB_WORKFLOW_REF%@*}")" + BASE="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/${WORKFLOW_FILE}/runs?branch=${GITHUB_REF_NAME}&event=push&status=success&per_page=1" \ + --jq '.workflow_runs[0].head_sha // empty')" || BASE="" + fi + if [[ -z "${BASE}" ]] || ! git cat-file -e "${BASE}^{commit}" 2> /dev/null; then + echo "No usable successful run to compare with, publishing everything" + echo "app=true" >> "$GITHUB_OUTPUT" + echo "chart=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Comparing with ${BASE}, the last successful run" + changed() { git diff --quiet "${BASE}" "${GITHUB_SHA}" -- "$@" && echo false || echo true; } + echo "app=$(changed . ':(exclude)deploy/helm')" >> "$GITHUB_OUTPUT" + echo "chart=$(changed deploy/helm/encryption .github)" >> "$GITHUB_OUTPUT" + release: name: Release - needs: [supply-chain, verify, test, e2e] - if: ${{ github.event.act || github.ref_name == 'main' || github.ref_type == 'tag' }} + needs: [supply-chain, verify, test, e2e, changes] + if: ${{ (github.event.act || github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/v')) && needs.changes.outputs.app == 'true' }} runs-on: ubuntu-latest timeout-minutes: 30 environment: release @@ -313,3 +388,80 @@ jobs: # # Deployment must be handled outside this repository, either by using GitHub hooks, regular checks, or by manually deploying # + + release-chart: + name: Release the chart + needs: [helm, changes] + # Either having the chart release tag, or the main branch to have the same behavior than Docker images when tests are needed + if: ${{ (github.event.act || github.ref_name == 'testci' || startsWith(github.ref, 'refs/tags/chart/v')) && needs.changes.outputs.chart == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + + - name: Install Helm + uses: ./.github/actions/setup-helm + + - name: Compute the chart version # `chart/v1.4.0` -> `1.4.0`, a branch build -> `0.0.0-main` + id: chart-meta + env: + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + # A branch build publishes one fixed version that the next build overwrites, the chart's + # counterpart of the image's `main` tag. Not `main` itself: a chart version must be + # SemVer and Helm refuses it. A pre-release is also skipped when Helm resolves the + # latest version, so an install that does not name this version never gets it. + run: | + if [[ "${REF_TYPE}" == "tag" ]]; then + VERSION="${REF_NAME#chart/v}" + [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::${REF_NAME} is not chart/vX.Y.Z"; exit 1; } + else + VERSION="0.0.0-main" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Package the chart + env: + VERSION: ${{ steps.chart-meta.outputs.version }} + run: helm package deploy/helm/encryption --version "${VERSION}" --destination /tmp/chart + + - name: Log in to the container registry + # Docker's credential file, not `helm registry login`: the attestation push reads only that file, and Helm falls back to it + if: ${{ !github.event.act }} + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ${{ env.CONTAINER_REGISTRY }} + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: Push the chart + id: chart-push + if: ${{ !github.event.act }} + env: + VERSION: ${{ steps.chart-meta.outputs.version }} + # Through `tee`, not `$(...)`: under `bash -e` a failed substitution exits before its + # output is printed, which hides Helm's error. `pipefail` keeps that failure fatal. + run: | + set -o pipefail + helm push "/tmp/chart/encryption-chart-${VERSION}.tgz" oci://registry-1.docker.io/lasuite 2>&1 | tee "${RUNNER_TEMP}/helm-push.log" + DIGEST="$(sed -n 's/^Digest: //p' "${RUNNER_TEMP}/helm-push.log")" + [ -n "${DIGEST}" ] || { echo "::error::helm push printed no digest"; exit 1; } + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "Pin it as \`${CHART_IMAGE}:${VERSION}@${DIGEST}\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Publish the build provenance + # Same Sigstore attestation as the image; `cosign verify-attestation` on the chart digest reads it (see deploy/helm/encryption/README.md) + if: ${{ !github.event.act }} + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-name: ${{ env.CONTAINER_REGISTRY }}/${{ env.CHART_IMAGE }} + subject-digest: ${{ steps.chart-push.outputs.digest }} + push-to-registry: true diff --git a/.prettierignore b/.prettierignore index edf9680..d602900 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,7 @@ /.cache/ /.github/CODEOWNERS /build/ +/deploy/helm/encryption/templates/ /dist/ /node_modules/ /src/generated/ diff --git a/CLAUDE.md b/CLAUDE.md index 023e820..2a68dd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ Single `package.json`, no workspaces. Source in `src/` with clear module separat - `src/demo/` — fake product pages for testing (two instances on different ports) - `src/i18n/` — French translations, i18next setup - `src/build/` — build-time helpers (browser versions from browserslist) +- `deploy/` — deployment material: PostgreSQL role scripts, the Helm chart and its tests (`deploy/helm/encryption.test.ts` renders it with the real `helm` binary and checks the Deployment against `src/server/env-schema.ts`), helmfile and Argo CD examples. The chart is versioned apart from the application: `chart/vX.Y.Z` tags publish `lasuite/encryption-chart` on Docker Hub and run only the chart jobs; `vX.Y.Z` tags release the image and skip the chart. A push to the CI branch republishes the image as `main` only when something outside `deploy/helm` changed, and the chart as `0.0.0-main` only when `deploy/helm/encryption` or `.github` changed, both compared with the commit of the last successful run on the branch (the `changes` job), so a failed release is published again by the next push. `image.tag` is therefore required in the values. - `.storybook/` — Storybook config with factory pattern from assistant-declaration ## Tech stack @@ -217,6 +218,7 @@ exercise). Open http://localhost:7209 and log in as `dev@example.com` / npm run dev # Start server (API + vault + UI) + demos + storybook npm run build # Build server + vault + UI + client SDK npm run test:unit # Run tests +npm run test:helm # Render and check the Helm chart in deploy/helm (needs helm + kubeconform) npm run test:e2e:headless # Render every story in a headless browser npm run test:e2e # Same, with the browser visible npm run lint # ESLint + TypeScript check diff --git a/README.md b/README.md index 095d16e..3a77a17 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ In development, a single Fastify server on port 7200 embeds Vault and UI via Vit npm run dev # Start server (API + vault + UI) + demos + storybook npm run build # Build server + vault + UI + client SDK npm run test:unit # Run tests +npm run test:helm # Render and check the Helm chart (needs helm + kubeconform) npm run lint # ESLint + TypeScript check npm run format # Prettier write npm run format:check # Prettier check @@ -155,6 +156,19 @@ The release is a single container image, `lasuite/encryption`, published on Dock - **Shutdown**: on `SIGTERM` the server stops accepting connections, drains, flushes pending error reports and exits. Give it a grace period of a few seconds. - **Egress needed**: PostgreSQL, the SMTP host(s), the OIDC provider (`OIDC_JWKS_URL`) and, if set, the host in `SENTRY_DSN`. Nothing else: no registry, no CDN, no download at startup. +### Kubernetes + +A Helm chart, [`deploy/helm/encryption`](deploy/helm/encryption), deploys the image as described on this page: the Deployment with the hardening of `docker-compose.production.yaml`, one Service for both hostnames, the migration Job as a pre-upgrade hook with the migrator role, and optional Ingress, network policy and error reporting. It manages none of the dependencies and has no default image: `image.tag` is required, because the chart is versioned on its own. A `chart/vX.Y.Z` git tag publishes it to Docker Hub as an OCI artifact, `lasuite/encryption-chart`, attested like the image; an application tag never touches it, and a chart tag runs nothing else. + +Take it by version, keep the digest Helm prints, verify the digest with `cosign verify-attestation` exactly as for the image, then reference it as tag plus digest, which Helm refuses to deviate from: + +```sh +helm pull oci://registry-1.docker.io/lasuite/encryption-chart --version 1.4.0 # prints Digest: sha256:… +helm install encryption oci://registry-1.docker.io/lasuite/encryption-chart:1.4.0@sha256:… --values my-values.yaml +``` + +Its values are schema-checked, so a typo or a missing required value fails the install rather than the pod. `npm run test:helm` renders it and checks it against the Kubernetes API schemas and against the server's own environment contract; `deploy/helm/smoke-k3d.sh` installs it for real in a local k3d cluster. Examples for helmfile and Argo CD live in [`deploy/helmfile`](deploy/helmfile) and [`deploy/argocd`](deploy/argocd). See [the chart README](deploy/helm/encryption/README.md). + ### Environment Every variable is listed in [`.env.model`](.env.model) with a production-shaped value; the server refuses to start and prints the offending names when one is missing or malformed. The ones that shape the deployment: @@ -195,7 +209,7 @@ docker run --rm \ The command is idempotent and exits non-zero when a migration fails, which is what you want a deployment to stop on. Where it belongs: -- **Kubernetes**: a `Job` run as a Helm `pre-install`/`pre-upgrade` hook, or an Argo CD `PreSync` hook. A failed migration is then a failed Job and the rollout does not start. Not an init container: it would run on every replica and every restart, and each of those pods would need the migrator credentials. +- **Kubernetes**: a `Job` run as a Helm `pre-install`/`pre-upgrade` hook, or an Argo CD `PreSync` hook, which is what the chart does. A failed migration is then a failed Job and the rollout does not start. Not an init container: it would run on every replica and every restart, and each of those pods would need the migrator credentials. - **Plain Docker**: a release step in the pipeline, before the new version starts. - **PaaS that builds from source** (Scalingo, Clever Cloud, Heroku-like): there is no image, the platform runs `npm ci` and `npm run build` itself, so the migration is an npm script run between the build and the start, with the migrator credentials, then the server starts with the runtime ones. Scalingo, for example, runs the `postdeploy` entry of the `Procfile` after the build and before the new release takes traffic: diff --git a/deploy/argocd/application.example.yaml b/deploy/argocd/application.example.yaml new file mode 100644 index 0000000..482cae9 --- /dev/null +++ b/deploy/argocd/application.example.yaml @@ -0,0 +1,46 @@ +# An Argo CD Application taking the chart straight from this repository at a release +# tag, with the values kept in the deployment's own repository. Copy, rename, fill in. +# +# What pins what: +# - `targetRevision: chart/vX.Y.Z` pins the chart to the content of that tag. A commit +# id hashes the whole tree, so the content cannot change under the same id; what CAN +# change is where the tag points, hence the tag protection and the signature check +# in appproject.example.yaml. Put the commit id itself there for a pin no tag move +# can affect. +# - `image.digest` (in the values) pins the image to exact bytes, verified beforehand +# with `cosign verify-attestation` (see docker-compose.production.yaml). The tag +# alone would follow whatever the registry serves. +# +# The OCI artifact works too, version-pinned (Argo CD does not take a digest here): +# - repoURL: registry-1.docker.io/lasuite +# chart: encryption-chart +# targetRevision: 1.4.0 +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: encryption + namespace: argocd +spec: + project: encryption + destination: + server: https://kubernetes.default.svc + namespace: encryption + sources: + # The chart, from the application repository, at the release tag. + - repoURL: https://github.com/suitenumerique/encryption.git + targetRevision: chart/v1.4.0 + path: deploy/helm/encryption + helm: + valueFiles: + - $deployment/encryption/values.yaml + # The values, from the deployment repository (secrets as existingSecret names only). + - repoURL: https://github.com/example-org/deployments.git + targetRevision: main + ref: deployment + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ServerSideApply=true diff --git a/deploy/argocd/appproject.example.yaml b/deploy/argocd/appproject.example.yaml new file mode 100644 index 0000000..7fbddbb --- /dev/null +++ b/deploy/argocd/appproject.example.yaml @@ -0,0 +1,49 @@ +# The project the Application above belongs to. Its job is to make Argo CD refuse a +# revision nobody trusted: +# +# - `signatureKeys`: a sync only happens when the commit at `targetRevision` (for an +# annotated tag, the tag itself) is signed by one of these GPG keys. Import the keys +# under Settings > GPG keys first. List the release managers' keys, and GitHub's +# web-flow key (4AEE18F83AFDEB23) only if releases are cut from the web interface, +# which means trusting GitHub the way the image build already does. +# - `sourceRepos`: only these two repositories may feed the project. +# +# Pair it with a GitHub ruleset on the application repository: tags matching `v*` and +# `chart/v*` cannot be moved or deleted, and must be signed. +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: encryption + namespace: argocd +spec: + description: The encryption service, chart from its release tags + sourceRepos: + - https://github.com/suitenumerique/encryption.git + - https://github.com/example-org/deployments.git + destinations: + - server: https://kubernetes.default.svc + namespace: encryption + signatureKeys: + - keyID: 0123456789ABCDEF # a release manager + clusterResourceWhitelist: [] + namespaceResourceWhitelist: + - group: '' + kind: ConfigMap + - group: '' + kind: Secret + - group: '' + kind: Service + - group: '' + kind: ServiceAccount + - group: '' + kind: Pod + - group: apps + kind: Deployment + - group: batch + kind: Job + - group: networking.k8s.io + kind: Ingress + - group: networking.k8s.io + kind: NetworkPolicy + - group: policy + kind: PodDisruptionBudget diff --git a/deploy/helm/__snapshots__/encryption.test.ts.snap b/deploy/helm/__snapshots__/encryption.test.ts.snap new file mode 100644 index 0000000..16c1d0a --- /dev/null +++ b/deploy/helm/__snapshots__/encryption.test.ts.snap @@ -0,0 +1,522 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`the chart > renders the same objects as last time (review the snapshot diff on any change) 1`] = ` +"--- +# Source: encryption-chart/templates/networkpolicy.yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: enc-encryption + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - port: 7200 + protocol: TCP + from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + egress: + # Name resolution, wherever the cluster DNS lives. + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + - ports: + - port: 5432 + protocol: TCP + to: + - ipBlock: + cidr: 10.0.0.0/8 +--- +# Source: encryption-chart/templates/pdb.yaml +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: enc-encryption + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc +--- +# Source: encryption-chart/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: enc-encryption + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm +automountServiceAccountToken: false +--- +# Source: encryption-chart/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: enc-encryption + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm +type: Opaque +stringData: + DATABASE_URL: "postgresql://encryption_runtime:runtime-password@postgres.example.org:5432/encryption?schema=encryption" + SENTRY_DSN: "https://11111111111111111111111111111111@glitchtip.example.org/1" + MAILER_SMTP_USER: "relay-user" + MAILER_SMTP_PASSWORD: "relay-password" +--- +# Source: encryption-chart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: enc-encryption + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + selector: + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc +--- +# Source: encryption-chart/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: enc-encryption + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm +spec: + replicas: 3 + revisionHistoryLimit: 3 + selector: + matchLabels: + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + template: + metadata: + labels: + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + annotations: + # A changed inline secret rolls the pods, since env from a Secret is read at start. + checksum/secret: 2bde9da0a89779539ca534197e8a2e9867ae0ef5d865b5c89b30c67212c215ac + spec: + serviceAccountName: enc-encryption + # The server never talks to the Kubernetes API. + automountServiceAccountToken: false + securityContext: + fsGroup: 65532 + runAsGroup: 65532 + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: + type: RuntimeDefault + terminationGracePeriodSeconds: 30 + containers: + - name: server + image: "lasuite/encryption:1.2.3" + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + ports: + - name: http + containerPort: 7200 + protocol: TCP + env: + - name: NODE_ENV + value: production + - name: PORT + value: "7200" + - name: VAULT_URL + value: "https://data.encryption.example.org" + - name: UI_URL + value: "https://encryption.example.org" + - name: ALLOWED_FRAME_ANCESTORS + value: "https://docs.example.org,https://drive.example.org" + - name: OIDC_ISSUER + value: "https://auth.example.org/realms/suite" + - name: OIDC_JWKS_URL + value: "https://auth.example.org/realms/suite/protocol/openid-connect/certs" + - name: OIDC_CLIENT_ID + value: "encryption" + - name: OIDC_SERVER_CLIENT_ID + value: "encryption" + - name: OIDC_REDIRECT_URI + value: "https://encryption.example.org/auth/callback" + - name: OIDC_ACCEPT_UNVERIFIED_EMAIL + value: "false" + - name: OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION + value: "false" + - name: DOCS_ENABLED + value: "true" + - name: MAILER_SMTP_HOST + value: "smtp.example.org" + - name: MAILER_SMTP_PORT + value: "587" + - name: MAILER_SMTP_SECURE + value: "false" + - name: MAILER_SMTP_REQUIRE_TLS + value: "true" + - name: MAILER_SMTP_USER + valueFrom: + secretKeyRef: + name: "enc-encryption" + key: "MAILER_SMTP_USER" + - name: MAILER_SMTP_PASSWORD + valueFrom: + secretKeyRef: + name: "enc-encryption" + key: "MAILER_SMTP_PASSWORD" + - name: MAILER_FALLBACK_SMTP_HOST + value: "smtp-fallback.example.org" + - name: MAILER_FALLBACK_SMTP_PORT + value: "25" + - name: MAILER_FALLBACK_SMTP_SECURE + value: "false" + - name: MAILER_FALLBACK_SMTP_REQUIRE_TLS + value: "false" + - name: MAILER_DEFAULT_DOMAIN + value: "example.org" + - name: EMAIL_PRODUCT_URL + value: "https://docs.example.org" + - name: SECURITY_CONTACT_URL + value: "mailto:security@example.org" + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: "enc-encryption" + key: "DATABASE_URL" + - name: SENTRY_DSN + valueFrom: + secretKeyRef: + name: "enc-encryption" + key: "SENTRY_DSN" + - name: SENTRY_ENVIRONMENT + value: "production" + - name: EMAIL_PALETTE_PATH + value: /config/palette.json + startupProbe: + httpGet: + path: /health + port: http + failureThreshold: 30 + periodSeconds: 2 + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + timeoutSeconds: 2 + livenessProbe: + httpGet: + path: /health + port: http + failureThreshold: 3 + periodSeconds: 20 + timeoutSeconds: 2 + resources: + limits: + memory: 1Gi + requests: + cpu: 100m + memory: 256Mi + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi + topologySpreadConstraints: + - + maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc +--- +# Source: encryption-chart/templates/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: enc-encryption + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm + annotations: + cert-manager.io/cluster-issuer: letsencrypt +spec: + ingressClassName: "nginx" + tls: + - secretName: "enc-encryption-tls" + hosts: + - "data.encryption.example.org" + - "encryption.example.org" + rules: + - host: "data.encryption.example.org" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: enc-encryption + port: + name: http + - host: "encryption.example.org" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: enc-encryption + port: + name: http +--- +# Source: encryption-chart/templates/migration-job.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: enc-encryption-migrate + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: migration + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +automountServiceAccountToken: false +--- +# Source: encryption-chart/templates/migration-job.yaml +apiVersion: v1 +kind: Secret +metadata: + name: enc-encryption-migrate + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: migration + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +type: Opaque +stringData: + DATABASE_URL: "postgresql://encryption_migrator:migrator-password@postgres.example.org:5432/encryption?schema=encryption" +--- +# Source: encryption-chart/templates/tests/health.yaml +apiVersion: v1 +kind: Pod +metadata: + name: enc-encryption-test-health + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: test + annotations: + helm.sh/hook: test + # Not deleted on success: \`helm test --logs\` reads the pod after it finished. The + # next run replaces it. + helm.sh/hook-delete-policy: before-hook-creation +spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + fsGroup: 65532 + runAsGroup: 65532 + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: health + image: "lasuite/encryption:1.2.3" + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + env: + - name: HEALTH_URL + value: "http://enc-encryption:80/health" + args: + - -e + - | + const url = process.env.HEALTH_URL; + fetch(url) + .then(async (response) => { + console.log(\`\${url} -> \${response.status} \${await response.text()}\`); + if (!response.ok) process.exit(1); + }) + .catch((error) => { + console.error(\`\${url} -> \${error}\`); + process.exit(1); + }); + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi +--- +# Source: encryption-chart/templates/migration-job.yaml +apiVersion: batch/v1 +kind: Job +metadata: + # The release revision in the name: a hook Job is immutable once created, so the + # name must change every time Helm has to create it again. + name: enc-encryption-migrate-1 + namespace: "encryption" + labels: + helm.sh/chart: encryption-chart-0.0.0-dev + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/version: "1.2.3" + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: migration + annotations: + # Before the Deployment is touched, on install and on upgrade. Argo CD reads the + # same annotations and runs it as a PreSync hook. A failed Job stays for its logs; + # a succeeded one is removed, and a stale one is replaced on the next run. + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "0" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 1 + activeDeadlineSeconds: 600 + template: + metadata: + labels: + app.kubernetes.io/name: encryption + app.kubernetes.io/instance: enc + app.kubernetes.io/component: migration + spec: + restartPolicy: Never + serviceAccountName: enc-encryption-migrate + automountServiceAccountToken: false + securityContext: + fsGroup: 65532 + runAsGroup: 65532 + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: migrate + image: "lasuite/encryption:1.2.3" + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + # The image's entrypoint is \`node\`; these are its arguments, the Prisma CLI + # the image ships for exactly this (see the Dockerfile). + args: + - node_modules/prisma/build/index.js + - migrate + - deploy + env: + - name: NODE_ENV + value: production + + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: "enc-encryption-migrate" + key: "DATABASE_URL" + resources: + limits: + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi +" +`; diff --git a/deploy/helm/encryption.test.ts b/deploy/helm/encryption.test.ts new file mode 100644 index 0000000..19c830e --- /dev/null +++ b/deploy/helm/encryption.test.ts @@ -0,0 +1,227 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseAllDocuments } from 'yaml'; + +import { envSchema } from '@encryption/src/server/env-schema'; + +/** + * Rendered by the real Helm binary (Go templates cannot be reproduced in JavaScript), + * then checked for what a render alone cannot say. The shape of the output is the + * committed snapshot, reviewed on every change; the assertions below are only the + * invariants that must hold in every configuration. The one that matters most reads + * `src/server/env-schema.ts`: the chart cannot fall behind the server without failing. + * + * Needs `helm` on the PATH (and `kubeconform` in CI): `brew install helm kubeconform`. + * `deploy/helm/smoke-k3d.sh` is the other layer, a real install in a local cluster. + */ + +const CHART = resolve(import.meta.dirname, 'encryption'); +const FULL_VALUES = resolve(CHART, 'ci/full-values.yaml'); +const EXISTING_SECRETS_VALUES = resolve(CHART, 'ci/existing-secrets-values.yaml'); + +interface EnvEntry { + name: string; + value?: string; + valueFrom?: { secretKeyRef?: { name: string; key: string } }; +} + +interface Manifest { + kind: string; + metadata: { name: string; annotations?: Record; labels?: Record }; + spec?: { template?: { spec: { serviceAccountName?: string; containers: { env: EnvEntry[] }[] } } }; +} + +function run(command: string, args: string[], input?: string): string { + try { + return execFileSync(command, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (error) { + const failure = error as { stderr?: string; stdout?: string; message: string }; + + throw new Error(`${command} ${args.join(' ')} failed:\n${failure.stderr || failure.stdout || failure.message}`, { cause: error }); + } +} + +function isInstalled(command: string, versionArgs: string[]): boolean { + try { + execFileSync(command, versionArgs, { stdio: 'ignore' }); + + return true; + } catch { + return false; + } +} + +function render(values: string, set: string[] = []): { text: string; docs: Manifest[] } { + const text = run('helm', ['template', 'enc', CHART, '--namespace', 'encryption', '--values', values, ...set.flatMap((entry) => ['--set', entry])]); + const docs = parseAllDocuments(text) + .map((document) => document.toJS() as Manifest | null) + .filter((document): document is Manifest => document !== null && typeof document === 'object'); + + return { text, docs }; +} + +function renderError(values: string, set: string[]): string { + try { + render(values, set); + } catch (error) { + return (error as Error).message; + } + + throw new Error('expected the render to fail'); +} + +function serverEnv(docs: Manifest[]): Map { + const deployment = docs.find((document) => document.kind === 'Deployment'); + const container = deployment?.spec?.template?.spec.containers[0]; + + if (!container) throw new Error('no Deployment in the render'); + + return new Map(container.env.map((entry) => [entry.name, entry])); +} + +// Variables the server refuses to boot without: those the schema rejects when absent. +const REQUIRED_VARIABLES = Object.entries(envSchema.shape) + .filter(([, schema]) => !schema.safeParse(undefined).success) + .map(([name]) => name); +const KNOWN_VARIABLES = new Set([...Object.keys(envSchema.shape), 'NODE_ENV']); +const SECRET_VARIABLE = /^(?:DATABASE_URL|SENTRY_DSN|MAILER_.*_(?:USER|PASSWORD))$/; + +// Renders happen while the suites are collected, so the guard has to run at import time. +if (!isInstalled('helm', ['version', '--short'])) { + throw new Error('The chart tests need the helm binary on the PATH (brew install helm, or https://helm.sh/docs/intro/install/)'); +} + +describe('the chart', () => { + const full = render(FULL_VALUES); + const minimal = render(EXISTING_SECRETS_VALUES); + + it('lints strictly and validates against the Kubernetes API schemas', () => { + for (const values of [FULL_VALUES, EXISTING_SECRETS_VALUES]) { + expect(run('helm', ['lint', '--strict', CHART, '--values', values])).toContain('0 chart(s) failed'); + } + + if (!isInstalled('kubeconform', ['-v'])) { + if (process.env.CI) throw new Error('kubeconform is required in CI'); + console.warn('kubeconform is not installed, skipping the API schema validation (brew install kubeconform)'); + + return; + } + + const cache = mkdtempSync(resolve(tmpdir(), 'kubeconform-')); + + for (const { text } of [full, minimal]) { + const summary = run('kubeconform', ['-strict', '-summary', '-kubernetes-version', '1.30.0', '-cache', cache], text); + + expect(summary).toMatch(/Invalid: 0/); + expect(summary).toMatch(/Errors: 0/); + } + }); + + it('renders the same objects as last time (review the snapshot diff on any change)', () => { + expect(full.text).toMatchSnapshot(); + }); + + it('passes every variable the server requires, and nothing the server does not read', () => { + for (const env of [serverEnv(full.docs), serverEnv(minimal.docs)]) { + for (const name of REQUIRED_VARIABLES) { + expect(env.has(name), `${name} is required by src/server/env-schema.ts`).toBe(true); + } + + for (const name of env.keys()) { + expect(KNOWN_VARIABLES.has(name), `${name} is not a variable the server reads`).toBe(true); + } + } + }); + + it('never puts a secret in a plain env value', () => { + for (const env of [serverEnv(full.docs), serverEnv(minimal.docs)]) { + for (const [name, entry] of env) { + if (!SECRET_VARIABLE.test(name)) continue; + + expect(entry.value, `${name} must come from a Secret`).toBeUndefined(); + expect(entry.valueFrom?.secretKeyRef, name).toBeDefined(); + } + } + + expect( + minimal.docs.some((document) => document.kind === 'Secret'), + 'existing secrets only: no Secret of its own' + ).toBe(false); + }); + + // The failure this guards against only shows on a real cluster: Helm runs pre-install + // hooks before it creates the release's objects and pre-upgrade hooks before it updates + // them. A migration Job reading the release's own ServiceAccount never gets a pod on a + // first install ("serviceaccount not found"), and one reading the release's Secret sees + // the previous revision's content on an upgrade. So whatever the Job reads must be a + // hook of both phases that runs before it, or something the chart does not render. + it('only gives the migration Job objects that exist before it runs', () => { + for (const { docs } of [full, minimal]) { + const job = docs.find((document) => document.kind === 'Job'); + + if (!job?.spec?.template) throw new Error('no migration Job in the render'); + + const weight = (document: Manifest) => Number(document.metadata.annotations?.['helm.sh/hook-weight'] ?? 0); + const runsBeforeJob = (document: Manifest) => { + const phases = document.metadata.annotations?.['helm.sh/hook']?.split(',') ?? []; + + return phases.includes('pre-install') && phases.includes('pre-upgrade') && weight(document) < weight(job); + }; + const rendered = (kind: string, name: string) => docs.find((document) => document.kind === kind && document.metadata.name === name); + + const accountName = job.spec.template.spec.serviceAccountName ?? 'default'; + const account = rendered('ServiceAccount', accountName); + + expect(account === undefined || runsBeforeJob(account), `ServiceAccount ${accountName} must be a hook running before the Job`).toBe(true); + expect(accountName, 'the migrator must not share the server account').not.toBe( + docs.find((document) => document.kind === 'Deployment')?.spec?.template?.spec.serviceAccountName + ); + + for (const entry of job.spec.template.spec.containers[0].env) { + const secretName = entry.valueFrom?.secretKeyRef?.name; + + if (!secretName) continue; + + const secret = rendered('Secret', secretName); + + expect(secret === undefined || runsBeforeJob(secret), `Secret ${secretName} (${entry.name}) must be a hook running before the Job`).toBe( + true + ); + } + } + }); + + it('requires on tokens the client the interface signs in with', () => { + for (const env of [serverEnv(full.docs), serverEnv(minimal.docs)]) { + // The server checks `azp` against OIDC_SERVER_CLIENT_ID, and the interface's tokens + // are issued to OIDC_CLIENT_ID: any difference rejects every call it makes. + expect(env.get('OIDC_SERVER_CLIENT_ID')?.value).toBe(env.get('OIDC_CLIENT_ID')?.value); + } + }); + + it('fails early, naming the value, instead of rendering a pod that cannot boot', () => { + expect(renderError(FULL_VALUES, ['extraEnvs=1'])).toMatch(/extraEnvs.*not allowed|not allowed.*extraEnvs/); + expect(renderError(FULL_VALUES, ['config.oidc.issuer=http://insecure.example.org'])).toMatch(/issuer/); + expect(renderError(FULL_VALUES, ['hosts.vault='])).toContain('hosts.vault is required'); + expect(renderError(FULL_VALUES, ['database.url='])).toContain('database.url or database.existingSecret.name is required'); + expect(renderError(EXISTING_SECRETS_VALUES, ['image.digest='])).toContain('image.tag or image.digest is required'); + }); + + it('renders nothing for what is off', () => { + const { docs } = render(EXISTING_SECRETS_VALUES, ['database.migration.enabled=false', 'sentry.enabled=false', 'replicaCount=1']); + const kinds = new Set(docs.map((document) => document.kind)); + + expect(kinds.has('Job')).toBe(false); + expect( + docs.filter((document) => document.metadata.labels?.['app.kubernetes.io/component'] === 'migration').map((document) => document.kind), + 'no migration hook object without the migration' + ).toEqual([]); + expect(kinds.has('Ingress')).toBe(false); + expect(kinds.has('NetworkPolicy')).toBe(false); + expect(kinds.has('PodDisruptionBudget')).toBe(false); + expect([...serverEnv(docs).keys()].filter((name) => name.startsWith('SENTRY_'))).toEqual([]); + }); +}); diff --git a/deploy/helm/encryption/.helmignore b/deploy/helm/encryption/.helmignore new file mode 100644 index 0000000..a4a64a3 --- /dev/null +++ b/deploy/helm/encryption/.helmignore @@ -0,0 +1,5 @@ +__snapshots__/ +ci/ +*.test.ts +.helmignore +.DS_Store diff --git a/deploy/helm/encryption/Chart.yaml b/deploy/helm/encryption/Chart.yaml new file mode 100644 index 0000000..0b6114c --- /dev/null +++ b/deploy/helm/encryption/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +type: application +name: encryption-chart +description: End-to-end encryption service for LaSuite +home: https://github.com/suitenumerique/encryption +sources: + - https://github.com/suitenumerique/encryption +version: 0.0.0-dev # The CI pipeline will set the version depending on the release tag +kubeVersion: '>=1.27.0-0' diff --git a/deploy/helm/encryption/README.md b/deploy/helm/encryption/README.md new file mode 100644 index 0000000..472383f --- /dev/null +++ b/deploy/helm/encryption/README.md @@ -0,0 +1,88 @@ +# encryption Helm chart + +Deploys the encryption service: one Deployment running the `lasuite/encryption` image behind one Service answering on the two public hostnames, plus a migration Job run as a pre-install/pre-upgrade hook. Nothing else: PostgreSQL, the SMTP relay, the OIDC provider and an error collector are the operator's, the chart only takes their coordinates and never creates persistent storage. + +## Two versions, two tag families + +The chart and the application are versioned apart. A `vX.Y.Z` git tag releases the application image; a `chart/vX.Y.Z` tag releases the chart, and nothing else runs on it. The chart therefore has no default image: `image.tag` (or `image.digest`) is required, and a chart version says nothing about which application it deploys. Both can be tagged on the same commit when a release changes both; the rule that keeps that safe is that a chart tolerates the previous application version and vice versa, so they can also roll out separately. + +## Getting the chart + +It is published on Docker Hub as an OCI artifact, in its own repository next to the image: `lasuite/encryption-chart`. Pull by version, and keep the digest Helm prints: + +```sh +helm pull oci://registry-1.docker.io/lasuite/encryption-chart --version 1.4.0 +# Pulled: registry-1.docker.io/lasuite/encryption-chart:1.4.0 +# Digest: sha256:9888faa6… +``` + +Then check who built it before trusting it. The digest is only a checksum; the build provenance attestation is what binds it to a run of this repository's release workflow, signed through Sigstore and logged in the public ledger, exactly as for the image: + +```sh +cosign verify-attestation --type slsaprovenance1 \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github\.com/suitenumerique/encryption/\.github/workflows/ci\.yml@' \ + lasuite/encryption-chart@sha256:9888faa6… +``` + +From then on, reference the chart the way Docker references an image, tag for humans and digest for the machine. Helm resolves the digest and refuses anything else, whatever happens to the tag later: + +```sh +helm install encryption oci://registry-1.docker.io/lasuite/encryption-chart:1.4.0@sha256:9888faa6… \ + --namespace encryption --create-namespace --values my-values.yaml +``` + +Do the same for the image inside the values: verify `lasuite/encryption@` the same way (the command is in `docker-compose.production.yaml`) and set both `image.tag` and `image.digest`, which renders `lasuite/encryption:1.37.0@sha256:…`. + +### The test build + +A push to the CI branch that changes this directory (or the workflows) since the last successful run also publishes the chart as `0.0.0-main`, the counterpart of the image's `main` tag, and the next such push overwrites it. It is for trying a chart change before tagging it, never for a real deployment. It is a SemVer pre-release because a chart version cannot be `main`, and Helm skips pre-releases when it resolves the latest version, so only an install that names it gets it: + +```sh +helm upgrade --install encryption oci://registry-1.docker.io/lasuite/encryption-chart --version 0.0.0-main \ + --namespace encryption --create-namespace --values my-values.yaml --set image.tag=main +``` + +Since the same version points at new content after each push, a tool that caches charts by version (Argo CD's repo server, a local `helm pull`) keeps serving the old one until it is refreshed: pull again, or hard-refresh the application. + +### With helmfile + +The chart can be a helmfile release from the OCI artifact or from this repository, with the values kept in the deployment's own files. A complete example lives in [`deploy/helmfile/`](../../helmfile): a `helmfile.yaml.gotmpl` with one environment per cluster and a `values.yaml.gotmpl` per environment. One limit, checked with helmfile 1.1: it resolves an OCI chart by semver `version` only and rejects a digest in `chart:`. To pin content from helmfile, take the chart from git with the commit id of the `chart/vX.Y.Z` tag as `ref`, which the example shows. + +### With Argo CD + +Two example manifests live in [`deploy/argocd/`](../../argocd): an `Application` that takes the chart from this repository at a `chart/vX.Y.Z` tag (or from the OCI artifact) with the values in a separate deployment repository, and an `AppProject` restricting which repositories may feed it. Both are commented with what pins what. + +## Values + +`values.yaml` documents every key; `values.schema.json` enforces them, so an unknown key or a wrong type fails `helm install` instead of producing a pod that starts without a variable. The required ones fail the render with their path in the message. + +Two complete examples live in [`ci/`](ci): [`full-values.yaml`](ci/full-values.yaml) turns every feature on with inline secrets, [`existing-secrets-values.yaml`](ci/existing-secrets-values.yaml) is the production shape with every secret read from Secrets you created beforehand. The chart test renders both. + +The shape to know: + +- `image.tag` and `image.digest`: which application to run, see above. +- `hosts.vault` and `hosts.interface`: the two public hostnames. TLS in front of them is mandatory. +- `config.*`: the non-secret variables, named after `.env.model`. +- `database.url` or `database.existingSecret`: the **runtime** role. `database.migration.*`: the **migrator** role, used by the hook Job only, with its own ServiceAccount (`database.migration.serviceAccount`, never the server's). The two roles are described in [`deploy/postgres/`](../../postgres). +- `sentry.enabled` with a DSN: error reporting, off by default. +- `extraEnv` and `extraEnvFrom`: raw Kubernetes `env` and `envFrom` entries for what the chart has no key for. They are appended, and Kubernetes keeps the first occurrence of a name, so they cannot override a chart-managed variable. +- `ingress.*`: one Ingress with a rule per host, both to the Service, one TLS entry covering both. Leave it disabled to route yourself. +- `podSecurityContext`, `securityContext`, `tmpVolume`, `resources`: the hardening of `docker-compose.production.yaml` (read-only root, no capabilities, non-root, memory-backed `/tmp` as the only writable path). +- `replicaCount`, `podDisruptionBudget`, `topologySpreadConstraints`, `networkPolicy`. + +## Migrations + +The Job named `-encryption-migrate-` runs `prisma migrate deploy` from the image with the migrator credentials before the Deployment is touched, on install and on every upgrade. Helm waits for it; a failure stops the rollout and the failed Job stays for its logs. Argo CD reads the same annotations and runs it as a `PreSync` hook. Set `database.migration.enabled: false` to run migrations elsewhere. + +Its ServiceAccount and, when the migrator URL is given inline, the Secret holding it are hooks too, created just before the Job. They cannot be ordinary objects of the release: Helm runs these hooks before it creates the release's objects on an install and before it updates them on an upgrade, so the Job would find no ServiceAccount on a first install and the previous revision's Secret after rotating the migrator's password. Both are deleted once the migration succeeded, so the migrator's credentials do not stay in the cluster between releases. An `existingSecret` is read as it is, and is yours to keep current. + +The migrator URL must reach PostgreSQL directly, not through a connection pooler in transaction mode (PgBouncer, for instance): Prisma serializes concurrent migrations with a session-level advisory lock, which such a pooler does not keep. + +## Tests + +Two layers, one fast and one real. + +`npm run test:helm` (helm and kubeconform on the PATH: `brew install helm kubeconform`). It lints, validates the values schema, renders both examples, checks every object against the Kubernetes API schemas, asserts on the rendered objects, and compares the full render to a committed snapshot so any change shows in review. The one assertion to keep in mind: the Deployment must pass every variable `src/server/env-schema.ts` requires, read from that schema, so adding a required variable to the server without adding it to the chart fails the build. Another one covers the migration hook: whatever the Job reads must be a hook that runs before it, or something the chart does not create. In CI this runs on pull requests, on `main` and on chart tags, never on application tags. + +`deploy/helm/smoke-k3d.sh` (docker, k3d, kubectl, helm) creates a throwaway k3s cluster with a PostgreSQL set up by the scripts of `deploy/postgres/`, imports a locally built image, installs the chart into a namespace enforcing the `restricted` Pod Security level, checks that the migration hook wrote its journal as the migrator role and left none of its objects behind, runs `helm test` (a pod inside the cluster calling `/health` through the Service), then rotates the migrator's password, upgrades in place and checks all of it again. This is what a render cannot show: admission, probes on the real image, the hook ordering against a real database, the rolling update. Local only, a couple of minutes, `KEEP=1` leaves the cluster up for `kubectl`. diff --git a/deploy/helm/encryption/ci/existing-secrets-values.yaml b/deploy/helm/encryption/ci/existing-secrets-values.yaml new file mode 100644 index 0000000..42b3160 --- /dev/null +++ b/deploy/helm/encryption/ci/existing-secrets-values.yaml @@ -0,0 +1,35 @@ +# The minimal production shape example: nothing sensitive in the values, every secret read from Secrets the operator created (or synced from a vault) beforehand. +image: + digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + +hosts: + vault: data.encryption.example.org + interface: encryption.example.org + +config: + allowedFrameAncestors: + - https://docs.example.org + oidc: + issuer: https://auth.example.org/realms/suite + jwksUrl: https://auth.example.org/realms/suite/protocol/openid-connect/certs + clientId: encryption + mailer: + smtpHost: smtp.example.org + existingSecret: + name: smtp-credentials + defaultDomain: example.org + emailProductUrl: https://docs.example.org + +database: + existingSecret: + name: encryption-database + key: runtime-url + migration: + existingSecret: + name: encryption-database + key: migrator-url + +sentry: + enabled: true + existingSecret: + name: encryption-sentry diff --git a/deploy/helm/encryption/ci/full-values.yaml b/deploy/helm/encryption/ci/full-values.yaml new file mode 100644 index 0000000..b6172fd --- /dev/null +++ b/deploy/helm/encryption/ci/full-values.yaml @@ -0,0 +1,68 @@ +# A complete example for with every optional feature on and every secret given inline. +image: + tag: 1.2.3 + +replicaCount: 3 + +hosts: + vault: data.encryption.example.org + interface: encryption.example.org + +config: + allowedFrameAncestors: + - https://docs.example.org + - https://drive.example.org + oidc: + issuer: https://auth.example.org/realms/suite + jwksUrl: https://auth.example.org/realms/suite/protocol/openid-connect/certs + clientId: encryption + acceptUnverifiedEmail: false + fallbackToEmailForIdentification: false + mailer: + smtpHost: smtp.example.org + smtpPort: 587 + requireTls: true + user: relay-user + password: relay-password + fallback: + smtpHost: smtp-fallback.example.org + smtpPort: 25 + requireTls: false + defaultDomain: example.org + emailProductUrl: https://docs.example.org + securityContactUrl: mailto:security@example.org + +database: + url: postgresql://encryption_runtime:runtime-password@postgres.example.org:5432/encryption?schema=encryption + migration: + url: postgresql://encryption_migrator:migrator-password@postgres.example.org:5432/encryption?schema=encryption + +sentry: + enabled: true + dsn: https://11111111111111111111111111111111@glitchtip.example.org/1 + environment: production + +extraEnv: + - name: EMAIL_PALETTE_PATH + value: /config/palette.json + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + +networkPolicy: + enabled: true + ingress: + from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + egress: + - to: + - ipBlock: + cidr: 10.0.0.0/8 + ports: + - port: 5432 + protocol: TCP diff --git a/deploy/helm/encryption/templates/NOTES.txt b/deploy/helm/encryption/templates/NOTES.txt new file mode 100644 index 0000000..e664f69 --- /dev/null +++ b/deploy/helm/encryption/templates/NOTES.txt @@ -0,0 +1,25 @@ +{{ .Chart.Name }} {{ .Chart.Version }} deployed as release {{ .Release.Name }}, running {{ include "encryption.image" . }}. + + vault https://{{ .Values.hosts.vault }} + interface https://{{ .Values.hosts.interface }} + +Both hostnames must reach the Service {{ include "encryption.fullname" . }} with the Host +header unchanged, over TLS. +{{- if not .Values.ingress.enabled }} +No Ingress was rendered (ingress.enabled is false): route them yourself. +{{- end }} +{{- if .Values.database.migration.enabled }} + +Migrations ran as a pre-{{ if eq .Release.Revision 1 }}install{{ else }}upgrade{{ end }} hook +with the migrator role. The server itself connects with the runtime role, which cannot +alter the schema (see deploy/postgres/ in the repository). +{{- else }} + +database.migration.enabled is false: apply migrations yourself, once per release, before +the new pods start (see "Migrations" in the repository README). +{{- end }} +{{- if not .Values.sentry.enabled }} + +Error reporting is off. Set sentry.enabled with a DSN to send server and browser errors +to a Sentry-compatible collector. +{{- end }} diff --git a/deploy/helm/encryption/templates/_helpers.tpl b/deploy/helm/encryption/templates/_helpers.tpl new file mode 100644 index 0000000..86046c5 --- /dev/null +++ b/deploy/helm/encryption/templates/_helpers.tpl @@ -0,0 +1,243 @@ +{{/* +Chart name, release-qualified name, labels: the standard trio. +*/}} +{{- define "encryption.name" -}} +{{- default "encryption" .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "encryption.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default "encryption" .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "encryption.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "encryption.selectorLabels" -}} +app.kubernetes.io/name: {{ include "encryption.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "encryption.labels" -}} +helm.sh/chart: {{ include "encryption.chart" . }} +{{ include "encryption.selectorLabels" . }} +app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.Version | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "encryption.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "encryption.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{- define "encryption.migrationServiceAccountName" -}} +{{- $sa := .Values.database.migration.serviceAccount }} +{{- if $sa.create }} +{{- default (printf "%s-migrate" (include "encryption.fullname" .)) $sa.name }} +{{- else }} +{{- default "default" $sa.name }} +{{- end }} +{{- end }} + +{{/* +The image reference, in Docker's own grammar: `repository:tag@digest`. The digest names +exact bytes and is what the runtime resolves; the tag is the label a human reads next to +it. Without a digest the tag alone is used, and one of the two is required: the chart is +versioned apart from the application and has no default image of its own. +*/}} +{{- define "encryption.image" -}} +{{- if not (or .Values.image.tag .Values.image.digest) }} +{{- fail "image.tag or image.digest is required: the chart has no default application version" }} +{{- end }} +{{- $reference := .Values.image.repository }} +{{- if .Values.image.tag }} +{{- $reference = printf "%s:%s" $reference .Values.image.tag }} +{{- end }} +{{- if .Values.image.digest }} +{{- $reference = printf "%s@%s" $reference .Values.image.digest }} +{{- end }} +{{- $reference }} +{{- end }} + +{{/* +A required scalar, with the value path in the message so the operator knows which key +to set. Usage: include "encryption.required" (list .Values.hosts.vault "hosts.vault") +*/}} +{{- define "encryption.required" -}} +{{- $value := index . 0 -}} +{{- $path := index . 1 -}} +{{- required (printf "%s is required" $path) $value -}} +{{- end }} + +{{/* +One env entry read from a Secret. Args: dict "root" $ "env" NAME "value" inline +"existing" {name,key} "key" KEY_IN_CHART_SECRET, and optionally "secretName" when the +inline value lives in a Secret other than the release's own. An existing Secret wins over +an inline value; nothing is rendered when neither is given. +*/}} +{{- define "encryption.secretEnv" -}} +{{- if .existing.name }} +- name: {{ .env }} + valueFrom: + secretKeyRef: + name: {{ .existing.name | quote }} + key: {{ .existing.key | default .key | quote }} +{{- else if .value }} +- name: {{ .env }} + valueFrom: + secretKeyRef: + name: {{ .secretName | default (include "encryption.fullname" .root) | quote }} + key: {{ .key | quote }} +{{- end }} +{{- end }} + +{{/* +Same for a user/password pair. Args: dict "root" $ "prefix" MAILER_SMTP "relay" +*/}} +{{- define "encryption.relayCredentialsEnv" -}} +{{- $userEnv := printf "%s_USER" .prefix -}} +{{- $passwordEnv := printf "%s_PASSWORD" .prefix -}} +{{- if .relay.existingSecret.name }} +- name: {{ $userEnv }} + valueFrom: + secretKeyRef: + name: {{ .relay.existingSecret.name | quote }} + key: {{ .relay.existingSecret.userKey | quote }} +- name: {{ $passwordEnv }} + valueFrom: + secretKeyRef: + name: {{ .relay.existingSecret.name | quote }} + key: {{ .relay.existingSecret.passwordKey | quote }} +{{- else if or .relay.user .relay.password }} +- name: {{ $userEnv }} + valueFrom: + secretKeyRef: + name: {{ include "encryption.fullname" .root | quote }} + key: {{ $userEnv | quote }} +- name: {{ $passwordEnv }} + valueFrom: + secretKeyRef: + name: {{ include "encryption.fullname" .root | quote }} + key: {{ $passwordEnv | quote }} +{{- end }} +{{- end }} + +{{/* +Whether the chart has to own a Secret: true as soon as one secret value is given +inline rather than through an existing Secret. +*/}} +{{- define "encryption.hasInlineSecrets" -}} +{{- $v := .Values -}} +{{- if or + (and $v.database.url (not $v.database.existingSecret.name)) + (and $v.sentry.enabled $v.sentry.dsn (not $v.sentry.existingSecret.name)) + (and (or $v.config.mailer.user $v.config.mailer.password) (not $v.config.mailer.existingSecret.name)) + (and (or $v.config.mailer.fallback.user $v.config.mailer.fallback.password) (not $v.config.mailer.fallback.existingSecret.name)) +-}}true{{- end -}} +{{- end }} + +{{/* +The server's environment, every variable of src/server/env-schema.ts that a deployment +must or may set. Plain values are inline; secrets come from a Secret, never as `value`. +*/}} +{{- define "encryption.env" -}} +{{- $v := .Values -}} +{{- $vaultHost := include "encryption.required" (list $v.hosts.vault "hosts.vault") -}} +{{- $interfaceHost := include "encryption.required" (list $v.hosts.interface "hosts.interface") -}} +- name: NODE_ENV + value: production +- name: PORT + value: "7200" +- name: VAULT_URL + value: {{ printf "https://%s" $vaultHost | quote }} +- name: UI_URL + value: {{ printf "https://%s" $interfaceHost | quote }} +- name: ALLOWED_FRAME_ANCESTORS + value: {{ include "encryption.required" (list (join "," $v.config.allowedFrameAncestors) "config.allowedFrameAncestors") | quote }} +- name: OIDC_ISSUER + value: {{ include "encryption.required" (list $v.config.oidc.issuer "config.oidc.issuer") | quote }} +- name: OIDC_JWKS_URL + value: {{ include "encryption.required" (list $v.config.oidc.jwksUrl "config.oidc.jwksUrl") | quote }} +- name: OIDC_CLIENT_ID + value: {{ include "encryption.required" (list $v.config.oidc.clientId "config.oidc.clientId") | quote }} +{{- /* +Not a value of its own: the server accepts a token only when its `azp` (the client it was +issued to) equals this variable, and every token the interface sends was issued to the +interface's client. Any other value turns each of the interface's API calls into a 403. +*/}} +- name: OIDC_SERVER_CLIENT_ID + value: {{ $v.config.oidc.clientId | quote }} +- name: OIDC_REDIRECT_URI + value: {{ $v.config.oidc.redirectUri | default (printf "https://%s/auth/callback" $interfaceHost) | quote }} +- name: OIDC_ACCEPT_UNVERIFIED_EMAIL + value: {{ $v.config.oidc.acceptUnverifiedEmail | toString | quote }} +- name: OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION + value: {{ $v.config.oidc.fallbackToEmailForIdentification | toString | quote }} +- name: DOCS_ENABLED + value: {{ $v.config.docsEnabled | toString | quote }} +- name: MAILER_SMTP_HOST + value: {{ include "encryption.required" (list $v.config.mailer.smtpHost "config.mailer.smtpHost") | quote }} +- name: MAILER_SMTP_PORT + value: {{ $v.config.mailer.smtpPort | toString | quote }} +- name: MAILER_SMTP_SECURE + value: {{ $v.config.mailer.secure | toString | quote }} +- name: MAILER_SMTP_REQUIRE_TLS + value: {{ $v.config.mailer.requireTls | toString | quote }} +{{- include "encryption.relayCredentialsEnv" (dict "root" $ "prefix" "MAILER_SMTP" "relay" $v.config.mailer) }} +{{- if $v.config.mailer.fallback.smtpHost }} +- name: MAILER_FALLBACK_SMTP_HOST + value: {{ $v.config.mailer.fallback.smtpHost | quote }} +- name: MAILER_FALLBACK_SMTP_PORT + value: {{ $v.config.mailer.fallback.smtpPort | toString | quote }} +- name: MAILER_FALLBACK_SMTP_SECURE + value: {{ $v.config.mailer.fallback.secure | toString | quote }} +- name: MAILER_FALLBACK_SMTP_REQUIRE_TLS + value: {{ $v.config.mailer.fallback.requireTls | toString | quote }} +{{- include "encryption.relayCredentialsEnv" (dict "root" $ "prefix" "MAILER_FALLBACK_SMTP" "relay" $v.config.mailer.fallback) }} +{{- end }} +- name: MAILER_DEFAULT_DOMAIN + value: {{ include "encryption.required" (list $v.config.mailer.defaultDomain "config.mailer.defaultDomain") | quote }} +- name: EMAIL_PRODUCT_URL + value: {{ include "encryption.required" (list $v.config.emailProductUrl "config.emailProductUrl") | quote }} +{{- if $v.config.brandFont }} +- name: BRAND_FONT + value: {{ $v.config.brandFont | quote }} +{{- end }} +{{- if $v.config.securityContactUrl }} +- name: SECURITY_CONTACT_URL + value: {{ $v.config.securityContactUrl | quote }} +{{- end }} +{{- if not (or $v.database.url $v.database.existingSecret.name) }} +{{- fail "database.url or database.existingSecret.name is required" }} +{{- end }} +{{- include "encryption.secretEnv" (dict "root" $ "env" "DATABASE_URL" "value" $v.database.url "existing" $v.database.existingSecret "key" "DATABASE_URL") }} +{{- if $v.sentry.enabled }} +{{- if not (or $v.sentry.dsn $v.sentry.existingSecret.name) }} +{{- fail "sentry.dsn or sentry.existingSecret.name is required when sentry.enabled" }} +{{- end }} +{{- include "encryption.secretEnv" (dict "root" $ "env" "SENTRY_DSN" "value" $v.sentry.dsn "existing" $v.sentry.existingSecret "key" "SENTRY_DSN") }} +{{- if $v.sentry.environment }} +- name: SENTRY_ENVIRONMENT + value: {{ $v.sentry.environment | quote }} +{{- end }} +{{- if $v.sentry.release }} +- name: SENTRY_RELEASE + value: {{ $v.sentry.release | quote }} +{{- end }} +{{- end }} +{{- with $v.extraEnv }} +{{ toYaml . }} +{{- end }} +{{- end }} diff --git a/deploy/helm/encryption/templates/deployment.yaml b/deploy/helm/encryption/templates/deployment.yaml new file mode 100644 index 0000000..ceff948 --- /dev/null +++ b/deploy/helm/encryption/templates/deployment.yaml @@ -0,0 +1,119 @@ +{{- $env := include "encryption.env" . }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "encryption.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} + {{- with .Values.deploymentAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: 3 + selector: + matchLabels: + {{- include "encryption.selectorLabels" . | nindent 6 }} + {{- with .Values.strategy }} + strategy: + {{- toYaml . | nindent 4 }} + {{- end }} + template: + metadata: + labels: + {{- include "encryption.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + {{- if include "encryption.hasInlineSecrets" . }} + # A changed inline secret rolls the pods, since env from a Secret is read at start. + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- end }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "encryption.serviceAccountName" . }} + # The server never talks to the Kubernetes API. + automountServiceAccountToken: false + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + {{- with .Values.priorityClassName }} + priorityClassName: {{ . | quote }} + {{- end }} + containers: + - name: server + image: {{ include "encryption.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: 7200 + protocol: TCP + env: + {{- $env | nindent 12 }} + {{- with .Values.extraEnvFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + startupProbe: + httpGet: + path: /health + port: http + {{- toYaml .Values.probes.startup | nindent 12 }} + readinessProbe: + httpGet: + path: /health + port: http + {{- toYaml .Values.probes.readiness | nindent 12 }} + livenessProbe: + httpGet: + path: /health + port: http + {{- toYaml .Values.probes.liveness | nindent 12 }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: {{ .Values.tmpVolume.sizeLimit }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- range . }} + - {{- toYaml . | nindent 10 }} + labelSelector: + matchLabels: + {{- include "encryption.selectorLabels" $ | nindent 14 }} + {{- end }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/encryption/templates/ingress.yaml b/deploy/helm/encryption/templates/ingress.yaml new file mode 100644 index 0000000..a9f8fb6 --- /dev/null +++ b/deploy/helm/encryption/templates/ingress.yaml @@ -0,0 +1,40 @@ +{{- if .Values.ingress.enabled }} +{{- $fullName := include "encryption.fullname" . }} +{{- $vaultHost := include "encryption.required" (list .Values.hosts.vault "hosts.vault") }} +{{- $interfaceHost := include "encryption.required" (list .Values.hosts.interface "hosts.interface") }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" $fullName) | quote }} + hosts: + - {{ $vaultHost | quote }} + - {{ $interfaceHost | quote }} + {{- end }} + rules: + {{- range $host := list $vaultHost $interfaceHost }} + - host: {{ $host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ $fullName }} + port: + name: http + {{- end }} +{{- end }} diff --git a/deploy/helm/encryption/templates/migration-job.yaml b/deploy/helm/encryption/templates/migration-job.yaml new file mode 100644 index 0000000..83aa92b --- /dev/null +++ b/deploy/helm/encryption/templates/migration-job.yaml @@ -0,0 +1,134 @@ +{{- if .Values.database.migration.enabled }} +{{- $m := .Values.database.migration }} +{{- if not (or $m.url $m.existingSecret.name) }} +{{- fail "database.migration.url or database.migration.existingSecret.name is required when database.migration.enabled (set enabled: false to run migrations elsewhere)" }} +{{- end }} +{{- $hookName := printf "%s-migrate" (include "encryption.fullname" .) }} +{{- /* +Everything the Job reads is a hook of the same phases with a lower weight, never an +object of the release itself. Helm runs pre-install hooks before it creates the release's +objects and pre-upgrade hooks before it updates them, so a Job pointing at the release's +ServiceAccount or Secret finds the account missing on a first install ("serviceaccount +not found", the pod is never created) and the Secret one revision behind on an upgrade (a +key that did not exist yet, or the password from before a rotation). Created here, they +hold the current values every time. `hook-succeeded` deletes them once the whole phase has +succeeded (Helm deletes after the last hook, not after each), so the migrator's password +does not stay in the cluster between releases. +*/}} +{{- if $m.serviceAccount.create }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "encryption.migrationServiceAccountName" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} + app.kubernetes.io/component: migration + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + {{- with $m.serviceAccount.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: false +{{- end }} +{{- if and $m.url (not $m.existingSecret.name) }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $hookName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} + app.kubernetes.io/component: migration + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +type: Opaque +stringData: + DATABASE_URL: {{ $m.url | quote }} +{{- end }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + # The release revision in the name: a hook Job is immutable once created, so the + # name must change every time Helm has to create it again. + name: {{ include "encryption.fullname" . }}-migrate-{{ .Release.Revision }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} + app.kubernetes.io/component: migration + annotations: + # Before the Deployment is touched, on install and on upgrade. Argo CD reads the + # same annotations and runs it as a PreSync hook. A failed Job stays for its logs; + # a succeeded one is removed, and a stale one is replaced on the next run. + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "0" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + {{- with $m.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + backoffLimit: {{ $m.backoffLimit }} + activeDeadlineSeconds: {{ $m.activeDeadlineSeconds }} + template: + metadata: + labels: + {{- include "encryption.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: migration + spec: + restartPolicy: Never + serviceAccountName: {{ include "encryption.migrationServiceAccountName" . }} + automountServiceAccountToken: false + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: migrate + image: {{ include "encryption.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + # The image's entrypoint is `node`; these are its arguments, the Prisma CLI + # the image ships for exactly this (see the Dockerfile). + args: + - node_modules/prisma/build/index.js + - migrate + - deploy + env: + - name: NODE_ENV + value: production + {{- include "encryption.secretEnv" (dict "root" $ "env" "DATABASE_URL" "value" $m.url "existing" $m.existingSecret "key" "DATABASE_URL" "secretName" $hookName) | nindent 12 }} + {{- with $m.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: {{ .Values.tmpVolume.sizeLimit }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/encryption/templates/networkpolicy.yaml b/deploy/helm/encryption/templates/networkpolicy.yaml new file mode 100644 index 0000000..c556641 --- /dev/null +++ b/deploy/helm/encryption/templates/networkpolicy.yaml @@ -0,0 +1,34 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "encryption.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "encryption.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - port: 7200 + protocol: TCP + {{- with .Values.networkPolicy.ingress.from }} + from: + {{- toYaml . | nindent 8 }} + {{- end }} + egress: + # Name resolution, wherever the cluster DNS lives. + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + {{- with .Values.networkPolicy.egress }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/encryption/templates/pdb.yaml b/deploy/helm/encryption/templates/pdb.yaml new file mode 100644 index 0000000..2a973dc --- /dev/null +++ b/deploy/helm/encryption/templates/pdb.yaml @@ -0,0 +1,14 @@ +{{- if and .Values.podDisruptionBudget.enabled (gt (int .Values.replicaCount) 1) }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "encryption.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "encryption.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/deploy/helm/encryption/templates/secret.yaml b/deploy/helm/encryption/templates/secret.yaml new file mode 100644 index 0000000..19dbbdb --- /dev/null +++ b/deploy/helm/encryption/templates/secret.yaml @@ -0,0 +1,26 @@ +{{- if include "encryption.hasInlineSecrets" . }} +{{- $v := .Values }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "encryption.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if and $v.database.url (not $v.database.existingSecret.name) }} + DATABASE_URL: {{ $v.database.url | quote }} + {{- end }} + {{- if and $v.sentry.enabled $v.sentry.dsn (not $v.sentry.existingSecret.name) }} + SENTRY_DSN: {{ $v.sentry.dsn | quote }} + {{- end }} + {{- if and (or $v.config.mailer.user $v.config.mailer.password) (not $v.config.mailer.existingSecret.name) }} + MAILER_SMTP_USER: {{ $v.config.mailer.user | quote }} + MAILER_SMTP_PASSWORD: {{ $v.config.mailer.password | quote }} + {{- end }} + {{- if and (or $v.config.mailer.fallback.user $v.config.mailer.fallback.password) (not $v.config.mailer.fallback.existingSecret.name) }} + MAILER_FALLBACK_SMTP_USER: {{ $v.config.mailer.fallback.user | quote }} + MAILER_FALLBACK_SMTP_PASSWORD: {{ $v.config.mailer.fallback.password | quote }} + {{- end }} +{{- end }} diff --git a/deploy/helm/encryption/templates/service.yaml b/deploy/helm/encryption/templates/service.yaml new file mode 100644 index 0000000..99307af --- /dev/null +++ b/deploy/helm/encryption/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "encryption.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "encryption.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/encryption/templates/serviceaccount.yaml b/deploy/helm/encryption/templates/serviceaccount.yaml new file mode 100644 index 0000000..4bedebe --- /dev/null +++ b/deploy/helm/encryption/templates/serviceaccount.yaml @@ -0,0 +1,14 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "encryption.serviceAccountName" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: false +{{- end }} diff --git a/deploy/helm/encryption/templates/tests/health.yaml b/deploy/helm/encryption/templates/tests/health.yaml new file mode 100644 index 0000000..feffd37 --- /dev/null +++ b/deploy/helm/encryption/templates/tests/health.yaml @@ -0,0 +1,60 @@ +{{- /* +`helm test ` runs this pod inside the cluster after an install or upgrade: it +proves the Service resolves and the server answers, through the same network path the +Ingress will use. The application image is reused, so no extra image has to be allowed +in the cluster; its entrypoint is `node`, and fetch is built in. +*/}} +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "encryption.fullname" . }}-test-health + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "encryption.labels" . | nindent 4 }} + app.kubernetes.io/component: test + annotations: + helm.sh/hook: test + # Not deleted on success: `helm test --logs` reads the pod after it finished. The + # next run replaces it. + helm.sh/hook-delete-policy: before-hook-creation +spec: + restartPolicy: Never + automountServiceAccountToken: false + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 4 }} + {{- end }} + containers: + - name: health + image: {{ include "encryption.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + env: + - name: HEALTH_URL + value: {{ printf "http://%s:%d/health" (include "encryption.fullname" .) (int .Values.service.port) | quote }} + args: + - -e + - | + const url = process.env.HEALTH_URL; + fetch(url) + .then(async (response) => { + console.log(`${url} -> ${response.status} ${await response.text()}`); + if (!response.ok) process.exit(1); + }) + .catch((error) => { + console.error(`${url} -> ${error}`); + process.exit(1); + }); + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi diff --git a/deploy/helm/encryption/values.schema.json b/deploy/helm/encryption/values.schema.json new file mode 100644 index 0000000..47c061d --- /dev/null +++ b/deploy/helm/encryption/values.schema.json @@ -0,0 +1,555 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "encryption chart values", + "type": "object", + "additionalProperties": false, + "$defs": { + "hostname": { + "type": "string", + "pattern": "^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$" + }, + "httpsUrl": { + "type": "string", + "pattern": "^https://\\S+$" + }, + "optionalHttpsUrl": { + "type": "string", + "pattern": "^(?:https://\\S+)?$" + }, + "existingSecretKey": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + } + } + }, + "existingSecretCredentials": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "userKey": { + "type": "string", + "minLength": 1 + }, + "passwordKey": { + "type": "string", + "minLength": 1 + } + } + }, + "relay": { + "smtpHost": { + "type": "string" + }, + "smtpPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "secure": { + "type": "boolean" + }, + "requireTls": { + "type": "boolean" + }, + "user": { + "type": "string" + }, + "password": { + "type": "string" + }, + "existingSecret": { + "$ref": "#/$defs/existingSecretCredentials" + } + }, + "resources": { + "type": "object", + "additionalProperties": false, + "properties": { + "requests": { + "type": "object" + }, + "limits": { + "type": "object" + } + } + }, + "probe": { + "type": "object", + "additionalProperties": false, + "properties": { + "periodSeconds": { + "type": "integer", + "minimum": 1 + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "failureThreshold": { + "type": "integer", + "minimum": 1 + }, + "initialDelaySeconds": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "properties": { + "global": { + "type": "object" + }, + "image": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^(?:sha256:[0-9a-f]{64})?$" + }, + "pullPolicy": { + "type": "string", + "enum": ["IfNotPresent", "Always", "Never"] + }, + "pullSecrets": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "nameOverride": { + "type": "string" + }, + "fullnameOverride": { + "type": "string" + }, + "replicaCount": { + "type": "integer", + "minimum": 0 + }, + "hosts": { + "type": "object", + "additionalProperties": false, + "properties": { + "vault": { + "anyOf": [ + { + "const": "" + }, + { + "$ref": "#/$defs/hostname" + } + ] + }, + "interface": { + "anyOf": [ + { + "const": "" + }, + { + "$ref": "#/$defs/hostname" + } + ] + } + } + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "allowedFrameAncestors": { + "type": "array", + "items": { + "$ref": "#/$defs/httpsUrl" + } + }, + "oidc": { + "type": "object", + "additionalProperties": false, + "properties": { + "issuer": { + "$ref": "#/$defs/optionalHttpsUrl" + }, + "jwksUrl": { + "$ref": "#/$defs/optionalHttpsUrl" + }, + "clientId": { + "type": "string" + }, + "redirectUri": { + "$ref": "#/$defs/optionalHttpsUrl" + }, + "acceptUnverifiedEmail": { + "type": "boolean" + }, + "fallbackToEmailForIdentification": { + "type": "boolean" + } + } + }, + "mailer": { + "type": "object", + "additionalProperties": false, + "properties": { + "smtpHost": { + "$ref": "#/$defs/relay/smtpHost" + }, + "smtpPort": { + "$ref": "#/$defs/relay/smtpPort" + }, + "secure": { + "$ref": "#/$defs/relay/secure" + }, + "requireTls": { + "$ref": "#/$defs/relay/requireTls" + }, + "user": { + "$ref": "#/$defs/relay/user" + }, + "password": { + "$ref": "#/$defs/relay/password" + }, + "existingSecret": { + "$ref": "#/$defs/existingSecretCredentials" + }, + "fallback": { + "type": "object", + "additionalProperties": false, + "properties": { + "smtpHost": { + "$ref": "#/$defs/relay/smtpHost" + }, + "smtpPort": { + "$ref": "#/$defs/relay/smtpPort" + }, + "secure": { + "$ref": "#/$defs/relay/secure" + }, + "requireTls": { + "$ref": "#/$defs/relay/requireTls" + }, + "user": { + "$ref": "#/$defs/relay/user" + }, + "password": { + "$ref": "#/$defs/relay/password" + }, + "existingSecret": { + "$ref": "#/$defs/existingSecretCredentials" + } + } + }, + "defaultDomain": { + "type": "string" + } + } + }, + "emailProductUrl": { + "$ref": "#/$defs/optionalHttpsUrl" + }, + "docsEnabled": { + "type": "boolean" + }, + "brandFont": { + "type": "string" + }, + "securityContactUrl": { + "type": "string", + "pattern": "^(?:https://\\S+|mailto:\\S+)?$" + } + } + }, + "database": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string" + }, + "existingSecret": { + "$ref": "#/$defs/existingSecretKey" + }, + "migration": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "url": { + "type": "string" + }, + "existingSecret": { + "$ref": "#/$defs/existingSecretKey" + }, + "serviceAccount": { + "type": "object", + "additionalProperties": false, + "properties": { + "create": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "annotations": { + "type": "object" + } + } + }, + "backoffLimit": { + "type": "integer", + "minimum": 0 + }, + "activeDeadlineSeconds": { + "type": "integer", + "minimum": 1 + }, + "annotations": { + "type": "object" + }, + "resources": { + "$ref": "#/$defs/resources" + } + } + } + } + }, + "sentry": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "dsn": { + "type": "string" + }, + "existingSecret": { + "$ref": "#/$defs/existingSecretKey" + }, + "environment": { + "type": "string" + }, + "release": { + "type": "string" + } + } + }, + "extraEnv": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + } + }, + "extraEnvFrom": { + "type": "array", + "items": { + "type": "object" + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["ClusterIP", "NodePort", "LoadBalancer"] + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "annotations": { + "type": "object" + } + } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "className": { + "type": "string" + }, + "annotations": { + "type": "object" + }, + "tls": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "secretName": { + "type": "string" + } + } + } + } + }, + "podSecurityContext": { + "type": "object" + }, + "securityContext": { + "type": "object" + }, + "tmpVolume": { + "type": "object", + "additionalProperties": false, + "properties": { + "sizeLimit": { + "type": "string", + "pattern": "^[0-9]+(?:[EPTGMK]i?)?$" + } + } + }, + "resources": { + "$ref": "#/$defs/resources" + }, + "probes": { + "type": "object", + "additionalProperties": false, + "properties": { + "startup": { + "$ref": "#/$defs/probe" + }, + "readiness": { + "$ref": "#/$defs/probe" + }, + "liveness": { + "$ref": "#/$defs/probe" + } + } + }, + "strategy": { + "type": "object" + }, + "terminationGracePeriodSeconds": { + "type": "integer", + "minimum": 0 + }, + "podDisruptionBudget": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "minAvailable": { + "type": ["integer", "string"] + } + } + }, + "serviceAccount": { + "type": "object", + "additionalProperties": false, + "properties": { + "create": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "annotations": { + "type": "object" + } + } + }, + "networkPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "from": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "egress": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "topologySpreadConstraints": { + "type": "array", + "items": { + "type": "object" + } + }, + "priorityClassName": { + "type": "string" + }, + "nodeSelector": { + "type": "object" + }, + "tolerations": { + "type": "array", + "items": { + "type": "object" + } + }, + "affinity": { + "type": "object" + }, + "podAnnotations": { + "type": "object" + }, + "podLabels": { + "type": "object" + }, + "deploymentAnnotations": { + "type": "object" + } + } +} diff --git a/deploy/helm/encryption/values.yaml b/deploy/helm/encryption/values.yaml new file mode 100644 index 0000000..150c299 --- /dev/null +++ b/deploy/helm/encryption/values.yaml @@ -0,0 +1,227 @@ +# Values for the encryption chart. Every key is validated by values.schema.json: a typo +# in a key name or a wrong type fails `helm template` instead of producing a pod that +# boots without the variable. Values marked "required" fail the render when empty. +# +# The chart deploys the service and nothing it depends on. PostgreSQL, the SMTP relay, +# the OIDC provider and an error collector are external services the operator already +# runs; the chart takes their coordinates and never manages persistent storage. + +image: + repository: lasuite/encryption + # The application version to run. Required (or a digest): the chart is versioned on + # its own, by `chart/vX.Y.Z` tags, and has no opinion on which application it runs. + tag: '' + # The bytes to run. With both set the reference is "repository:tag@sha256:...", the + # tag for humans and the digest for the runtime, which refuses anything else. Take + # the digest from `cosign verify-attestation` (see docker-compose.production.yaml), + # never from a tag you did not verify. + digest: '' + pullPolicy: IfNotPresent + pullSecrets: [] + +nameOverride: '' +fullnameOverride: '' + +replicaCount: 2 + +# The two public hostnames. Required. TLS is not optional in front of them: the vault +# needs a secure context and the browser reporting endpoint is ignored over plain HTTP. +hosts: + vault: '' # e.g. data.encryption.example.com + interface: '' # e.g. encryption.example.com + +# Non-secret configuration, mirroring .env.model. Names follow the variables. +config: + # Product origins allowed to embed the iframes. Required, at least one. They must + # share one registrable domain (see the README, "Storage partitioning constraint"). + allowedFrameAncestors: [] + oidc: + issuer: '' # required, https://... + jwksUrl: '' # required + clientId: '' # required, the interface's public client (also the `azp` the server requires on tokens) + redirectUri: '' # default: https:///auth/callback + acceptUnverifiedEmail: false + fallbackToEmailForIdentification: false + mailer: + smtpHost: '' # required: the emergency-access notifications are a security control + smtpPort: 25 + secure: false # implicit TLS (465); leave false for STARTTLS on 25/587 + requireTls: true + # Credentials, either inline (written into a Secret the chart owns) or from an + # existing Secret. Leave everything empty for an unauthenticated relay. + user: '' + password: '' + existingSecret: + name: '' + userKey: MAILER_SMTP_USER + passwordKey: MAILER_SMTP_PASSWORD + # Optional second relay, tried when the primary send fails. + fallback: + smtpHost: '' + smtpPort: 25 + secure: false + requireTls: true + user: '' + password: '' + existingSecret: + name: '' + userKey: MAILER_FALLBACK_SMTP_USER + passwordKey: MAILER_FALLBACK_SMTP_PASSWORD + defaultDomain: '' # required, sender domain of the notifications + # Where email links send the user (the interface only exists inside a product). Required. + emailProductUrl: '' + docsEnabled: true + # Optional JSON for the brand font (family + woff URLs), see .env.model. + brandFont: '' + # Where researchers report a problem with THIS instance, published as + # /.well-known/security.txt. Empty = the path does not exist. + securityContactUrl: '' + +database: + # The runtime role's connection string (rows only, see deploy/postgres/). One of + # `url` (inline, written into a Secret the chart owns) or `existingSecret.name`. + url: '' + existingSecret: + name: '' + key: DATABASE_URL + migration: + # A Job run as a Helm pre-install/pre-upgrade hook (Argo CD maps it to PreSync): + # once per release, never per pod, and a failed migration stops the rollout. + enabled: true + # The migrator role's connection string. Same shape as above. + url: '' + existingSecret: + name: '' + key: DATABASE_URL + # Its own account, never the server's: the migrator is the one identity allowed to + # alter the schema, so anything granted through the account (a cloud IAM binding for + # database authentication, for instance) must not reach the long-lived pods. + serviceAccount: + create: true + name: '' + annotations: {} + backoffLimit: 1 + activeDeadlineSeconds: 600 + annotations: {} + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + +sentry: + # Error reporting to any Sentry-compatible collector (Sentry, GlitchTip). Disabled, + # nothing is sent and nothing else changes. See "Error reporting" in the README. + enabled: false + dsn: '' + existingSecret: + name: '' + key: SENTRY_DSN + environment: '' + # Empty = the commit the image was built from. + release: '' + +# Raw Kubernetes `env` entries appended after the chart's own (a value, a +# configMapKeyRef, a secretKeyRef, a fieldRef...). `extraEnvFrom` are `envFrom` +# entries (a whole Secret or ConfigMap). Both are for what the chart has no key for, +# and override nothing: Kubernetes keeps the FIRST occurrence of a variable name. +extraEnv: [] +extraEnvFrom: [] + +service: + type: ClusterIP + port: 80 + annotations: {} + +ingress: + enabled: false + className: '' + annotations: {} + # One rule per host, both to the same Service (the server routes on the Host header). + tls: + enabled: true + # Empty = "-tls". With cert-manager, add its annotation above and it + # creates this Secret for both hosts. + secretName: '' + +# Mirrors docker-compose.production.yaml: no shell, no root, nothing writable but /tmp. +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +# The only writable path. Memory-backed and bounded, like the compose tmpfs. +tmpVolume: + sizeLimit: 64Mi + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + # Node sizes its heap from this limit (--max-old-space-size-percentage in the image). + memory: 1Gi + +probes: + startup: + periodSeconds: 2 + failureThreshold: 30 + readiness: + periodSeconds: 10 + timeoutSeconds: 2 + liveness: + periodSeconds: 20 + timeoutSeconds: 2 + failureThreshold: 3 + +strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + +terminationGracePeriodSeconds: 30 + +podDisruptionBudget: + # Rendered only when replicaCount > 1, since a budget on a single replica blocks node drains. + enabled: true + minAvailable: 1 + +serviceAccount: + create: true + name: '' + annotations: {} + +networkPolicy: + # Off by default because the egress destinations (database, relay, OIDC provider, + # collector) are only known to the operator. Enabled, the pods accept traffic on + # the app port from `ingress.from` (empty = anywhere) and may only reach DNS plus + # the `egress` rules given here. + enabled: false + ingress: + from: [] + egress: [] + +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + +priorityClassName: '' +nodeSelector: {} +tolerations: [] +affinity: {} +podAnnotations: {} +podLabels: {} +deploymentAnnotations: {} diff --git a/deploy/helm/smoke-k3d.sh b/deploy/helm/smoke-k3d.sh new file mode 100755 index 0000000..f896702 --- /dev/null +++ b/deploy/helm/smoke-k3d.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# Deploys the chart for real into a throwaway local cluster (k3s inside Docker, via k3d) +# and runs its `helm test` hook. This is the layer the unit-level chart test cannot reach: +# admission (the namespace enforces the "restricted" Pod Security level), probes actually +# turning green on the distroless image with a read-only root, the migration hook against +# a real PostgreSQL with the two roles from deploy/postgres/, and an upgrade on top of an +# existing release that rotates the migrator's password. Local only, a couple of minutes. +# +# npm run build && docker build -t lasuite/encryption:smoke . +# deploy/helm/smoke-k3d.sh # uses lasuite/encryption:smoke +# IMAGE_TAG=1.2.3 deploy/helm/smoke-k3d.sh # a tag pulled from Docker Hub instead +# KEEP=1 deploy/helm/smoke-k3d.sh # leave the cluster up to poke at it +# +# Needs docker, k3d, kubectl and helm. +set -euo pipefail + +CLUSTER=${CLUSTER:-encryption-smoke} +NAMESPACE=encryption +RELEASE=enc +IMAGE_TAG=${IMAGE_TAG:-smoke} +CHART="$(cd "$(dirname "$0")" && pwd)/encryption" +ROLES="$(cd "$(dirname "$0")/../postgres" && pwd)" +# The server's PostgreSQL, pinned like in docker-compose.yaml. +POSTGRES_IMAGE=postgres:16.4@sha256:e62fbf9d3e2b49816a32c400ed2dba83e3b361e6833e624024309c35d334b412 + +for tool in docker k3d kubectl helm; do + command -v "$tool" > /dev/null || { echo "missing: $tool" >&2; exit 1; } +done + +cleanup() { + if [ "${KEEP:-0}" = "1" ]; then + echo "Cluster kept: kubectl --context k3d-${CLUSTER} -n ${NAMESPACE} get all" + else + k3d cluster delete "$CLUSTER" > /dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +k3d cluster delete "$CLUSTER" > /dev/null 2>&1 || true +k3d cluster create "$CLUSTER" --wait --timeout 120s --no-lb --k3s-arg '--disable=traefik@server:0' +export KUBECONFIG +KUBECONFIG="$(k3d kubeconfig write "$CLUSTER")" + +# A locally built image is imported straight into the cluster; anything else is pulled. +if docker image inspect "lasuite/encryption:${IMAGE_TAG}" > /dev/null 2>&1; then + k3d image import "lasuite/encryption:${IMAGE_TAG}" --cluster "$CLUSTER" +fi + +# PostgreSQL in a namespace of its own: the official image runs as root, which the +# restricted level below would refuse, and the database is not what is under test. +kubectl create namespace smoke-db +kubectl -n smoke-db apply -f - <&2; exit 1; } + leftovers=$(kubectl -n "$NAMESPACE" get serviceaccount,secret,job -l app.kubernetes.io/component=migration -o name) + [ -z "$leftovers" ] || { echo "the migration hook left objects behind: ${leftovers}" >&2; exit 1; } +} + +# A first install, where the hook runs before the release has created anything: it can +# only succeed if the Job's ServiceAccount and Secret are hooks themselves. +echo "== install" +install migrator-1 +check_migrated +echo "== helm test" +helm test "$RELEASE" --namespace "$NAMESPACE" --logs + +# An upgrade that rotates the migrator's password: the hook runs before the release's +# objects are updated, so it would authenticate with the previous password if it read them. +echo "== upgrade (revision 2: rotated migrator password, rolling update with zero unavailable)" +psql -c "ALTER ROLE encryption_migrator PASSWORD 'migrator-2'" +install migrator-2 --set podLabels.smoke=revision-2 +check_migrated +helm test "$RELEASE" --namespace "$NAMESPACE" --logs + +echo "== result" +kubectl -n "$NAMESPACE" get deployment,pod,service,pdb -o wide +kubectl -n "$NAMESPACE" get events --field-selector type=Warning -o custom-columns=REASON:.reason,MESSAGE:.message | sed 1d | sort -u | sed 's/^/warning: /' || true +echo "OK: ${RELEASE} migrates, deploys, answers /health and upgrades in place (with a rotated migrator password) under the restricted Pod Security level" diff --git a/deploy/helmfile/env/prod/values.yaml.gotmpl b/deploy/helmfile/env/prod/values.yaml.gotmpl new file mode 100644 index 0000000..b724fd9 --- /dev/null +++ b/deploy/helmfile/env/prod/values.yaml.gotmpl @@ -0,0 +1,64 @@ +# The values of one environment. Secrets are NOT here: they come from the SOPS file +# next to this one, or from Secrets that already exist in the cluster (existingSecret). +image: + # Both, always: the tag is what a human reads, the digest is what the node runs. + # Verify the digest with `cosign verify-attestation` (docker-compose.production.yaml) + # before writing it here. + tag: 1.37.0 + digest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + +replicaCount: 3 + +hosts: + vault: data.encryption.example.org + interface: encryption.example.org + +config: + allowedFrameAncestors: + - https://docs.example.org + - https://drive.example.org + oidc: + issuer: https://auth.example.org/realms/suite + jwksUrl: https://auth.example.org/realms/suite/protocol/openid-connect/certs + clientId: encryption + mailer: + smtpHost: smtp.example.org + smtpPort: 587 + existingSecret: + name: encryption-smtp + defaultDomain: example.org + emailProductUrl: https://docs.example.org + securityContactUrl: https://vdp.example.org/report + +database: + existingSecret: + name: encryption-database + key: runtime-url + migration: + existingSecret: + name: encryption-database + key: migrator-url + +sentry: + enabled: true + existingSecret: + name: encryption-sentry + environment: {{ .Environment.Name }} + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + +networkPolicy: + enabled: true + ingress: + from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + egress: + - to: + - ipBlock: + cidr: 10.0.0.0/8 # the database, the relay, the collector diff --git a/deploy/helmfile/helmfile.yaml.gotmpl b/deploy/helmfile/helmfile.yaml.gotmpl new file mode 100644 index 0000000..2c1aebd --- /dev/null +++ b/deploy/helmfile/helmfile.yaml.gotmpl @@ -0,0 +1,36 @@ +# A helmfile consuming the chart, the way the other Suite deployments are written: one +# environment per cluster, the values of each in env//values.yaml.gotmpl, secrets +# through helm-secrets (SOPS) in env//secrets.enc.yaml. Copy this directory into +# the deployment repository and fill in the environments. +# +# Pinning: helmfile (1.1 at the time of writing) resolves an OCI chart by its semver +# `version` only and rejects a `@sha256:` digest in `chart:`, so with the OCI artifact +# the pin is the version tag. For a content pin, use the git form below with the full +# commit id of the `chart/vX.Y.Z` tag as `ref`: a commit id hashes the whole tree. +environments: + preprod: + values: + - chartVersion: 1.4.0 + chartCommit: 0123456789abcdef0123456789abcdef01234567 # `git rev-parse chart/v1.4.0^{commit}` + secrets: + - env/{{ .Environment.Name }}/secrets.enc.yaml + prod: + values: + - chartVersion: 1.4.0 + chartCommit: 0123456789abcdef0123456789abcdef01234567 + secrets: + - env/{{ .Environment.Name }}/secrets.enc.yaml +--- + +releases: + - name: encryption + namespace: {{ .Namespace | default "encryption" }} + # The OCI artifact by version. The alternative, pinned to a commit id and needing no + # registry: + # chart: git::https://github.com/suitenumerique/encryption.git@deploy/helm/encryption?ref={{ .Values.chartCommit }} + chart: oci://registry-1.docker.io/lasuite/encryption-chart + version: {{ .Values.chartVersion }} + values: + - env/{{ .Environment.Name }}/values.yaml.gotmpl + secrets: + - env/{{ .Environment.Name }}/secrets.enc.yaml diff --git a/package-lock.json b/package-lock.json index d886ff8..e017fe9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,8 @@ "vite": "8.2.2", "vite-plugin-dts": "5.0.3", "vite-plugin-sri-gen": "1.3.2", - "vitest": "4.1.11" + "vitest": "4.1.11", + "yaml": "2.9.0" }, "engines": { "node": ">=24.19.0 <25.0.0", diff --git a/package.json b/package.json index a80e967..985d511 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,10 @@ "lint:es": "TIMING=1 eslint .", "lint:ts": "tsc --noEmit --incremental false", "test:prepare": "npm run db:schema:compile", - "test:unit": "vitest run --passWithNoTests", - "test:unit:watch": "vitest", + "test:unit": "vitest run --project unit --passWithNoTests", + "test:unit:watch": "vitest --project unit", + "test:helm": "vitest run --project helm", + "test:helm:smoke": "deploy/helm/smoke-k3d.sh", "test:e2e": "vitest run --config vitest.storybook.config.ts", "test:e2e:headless": "vitest run --config vitest.storybook.config.ts --browser.headless", "db:schema:compile": "prisma generate", @@ -175,7 +177,8 @@ "vite": "8.2.2", "vite-plugin-dts": "5.0.3", "vite-plugin-sri-gen": "1.3.2", - "vitest": "4.1.11" + "vitest": "4.1.11", + "yaml": "2.9.0" }, "packageManager": "npm@12.0.2", "engines": { diff --git a/renovate.json b/renovate.json index 34f6659..aa4895e 100644 --- a/renovate.json +++ b/renovate.json @@ -28,6 +28,22 @@ "depNameTemplate": "npm", "datasourceTemplate": "npm", "versioningTemplate": "npm" + }, + { + "customType": "regex", + "description": "Helm is downloaded as a tarball and checked against a pinned checksum rather than through an action, so no built-in manager sees it. Renovate can bump the version; the matching HELM_SHA256 has to be updated in the same pull request, and `sha256sum -c` fails the job if it is not.", + "managerFilePatterns": ["/^\\.github/actions/setup-helm/action\\.yml$/"], + "matchStrings": ["HELM_VERSION: (?v\\d+\\.\\d+\\.\\d+)"], + "depNameTemplate": "helm/helm", + "datasourceTemplate": "github-releases" + }, + { + "customType": "regex", + "description": "Same for kubeconform, with KUBECONFORM_SHA256.", + "managerFilePatterns": ["/^\\.github/actions/setup-helm/action\\.yml$/"], + "matchStrings": ["KUBECONFORM_VERSION: (?v\\d+\\.\\d+\\.\\d+)"], + "depNameTemplate": "yannh/kubeconform", + "datasourceTemplate": "github-releases" } ], "packageRules": [ diff --git a/src/server/env-schema.ts b/src/server/env-schema.ts new file mode 100644 index 0000000..dbb99ce --- /dev/null +++ b/src/server/env-schema.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; + +export const envSchema = z.object({ + PORT: z.coerce.number().default(7200), + HOST: z.string().default('0.0.0.0'), + DATABASE_URL: z.string(), + VAULT_URL: z.string().url(), + UI_URL: z.string().url(), + ALLOWED_FRAME_ANCESTORS: z.string(), + OIDC_JWKS_URL: z.string(), + OIDC_SERVER_CLIENT_ID: z.string(), + // OIDC configuration for the encryption interface (injected into the HTML at runtime) + OIDC_ISSUER: z.string().url(), + OIDC_CLIENT_ID: z.string(), + OIDC_REDIRECT_URI: z.string(), + OIDC_ACCEPT_UNVERIFIED_EMAIL: z.stringbool().default(false), + OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION: z.stringbool().default(false), + // `stringbool`, not `coerce.boolean`: the latter turns the string "false" into true. + DOCS_ENABLED: z.stringbool().default(true), + // Email delivery. The primary SMTP host is MANDATORY, not a convenience: the + // emergency-access wait period is only a protection for a grantor who actually + // learns a recovery started, so the notification is a security control. A + // deployment without a relay would start recoveries silently, which is why the + // server refuses to boot rather than degrade to console logging. + MAILER_SMTP_HOST: z.string().min(1), + MAILER_SMTP_PORT: z.coerce.number().default(25), + MAILER_SMTP_USER: z.string().optional(), + MAILER_SMTP_PASSWORD: z.string().optional(), + // Implicit TLS (port 465). Leave false for the usual STARTTLS-on-25/587 relay. + MAILER_SMTP_SECURE: z.stringbool().default(false), + // Refuse to deliver over a connection the relay never upgraded to TLS. Default + // on, so credentials and recipient addresses cannot silently cross in clear; + // local relays (maildev) that speak no TLS need it explicitly disabled. + MAILER_SMTP_REQUIRE_TLS: z.stringbool().default(true), + // Optional second relay, tried when the primary send fails. + MAILER_FALLBACK_SMTP_HOST: z.string().optional(), + MAILER_FALLBACK_SMTP_PORT: z.coerce.number().optional(), + MAILER_FALLBACK_SMTP_USER: z.string().optional(), + MAILER_FALLBACK_SMTP_PASSWORD: z.string().optional(), + MAILER_FALLBACK_SMTP_SECURE: z.stringbool().default(false), + MAILER_FALLBACK_SMTP_REQUIRE_TLS: z.stringbool().default(true), + // Sender domain for "Chiffrement " + MAILER_DEFAULT_DOMAIN: z.string().min(1), + // The product page email links point to: the encryption interface only exists + // embedded in products, so every notification needs somewhere to send the user. + EMAIL_PRODUCT_URL: z.string().url(), + // Optional path to a JSON file overriding email colours (keys of EmailPalette). + EMAIL_PALETTE_PATH: z.string().optional(), + // Optional error reporting to a Sentry-compatible collector (Sentry, GlitchTip). + // Leaving SENTRY_DSN unset disables reporting entirely and is a fully supported + // deployment mode: nothing in the service depends on a collector existing. + SENTRY_DSN: z.string().optional(), + SENTRY_ENVIRONMENT: z.string().optional(), + // Labels the deployed build in the collector. Defaults to the commit the image + // was built from (SOURCE_COMMIT, set by CI in the Dockerfile), else to the + // /api/version hash of the bundles. A label only: stacks are resolved inside the + // image, so nothing is ever matched against uploaded artifacts. + SENTRY_RELEASE: z.string().optional(), + SOURCE_COMMIT: z.string().optional(), + // Where a security researcher should report a problem with THIS deployment + // (published as /.well-known/security.txt). It belongs to whoever operates the + // instance, not to the code, so nothing is served until the operator sets it: an + // `mailto:` or an `https://` URL. + SECURITY_CONTACT_URL: z.preprocess( + (value) => (value === '' ? undefined : value), + z + .string() + .regex(/^(?:https:\/\/|mailto:)\S+$/) + .optional() + ), + // Optional JSON for the product brand font (BrandFont: family + woff URLs), + // shared by the emails, the Recovery Kit PDF and the interface UI. Unset = each + // surface's generic fallback. + BRAND_FONT: z.string().optional(), +}); diff --git a/src/server/env.ts b/src/server/env.ts index 417b2f8..b9b8180 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -1,77 +1,6 @@ import { z } from 'zod'; -const envSchema = z.object({ - PORT: z.coerce.number().default(7200), - HOST: z.string().default('0.0.0.0'), - DATABASE_URL: z.string(), - VAULT_URL: z.string().url(), - UI_URL: z.string().url(), - ALLOWED_FRAME_ANCESTORS: z.string(), - OIDC_JWKS_URL: z.string(), - OIDC_SERVER_CLIENT_ID: z.string(), - // OIDC configuration for the encryption interface (injected into the HTML at runtime) - OIDC_ISSUER: z.string().url(), - OIDC_CLIENT_ID: z.string(), - OIDC_REDIRECT_URI: z.string(), - OIDC_ACCEPT_UNVERIFIED_EMAIL: z.stringbool().default(false), - OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION: z.stringbool().default(false), - DOCS_ENABLED: z.coerce.boolean().default(true), - // Email delivery. The primary SMTP host is MANDATORY, not a convenience: the - // emergency-access wait period is only a protection for a grantor who actually - // learns a recovery started, so the notification is a security control. A - // deployment without a relay would start recoveries silently, which is why the - // server refuses to boot rather than degrade to console logging. - MAILER_SMTP_HOST: z.string().min(1), - MAILER_SMTP_PORT: z.coerce.number().default(25), - MAILER_SMTP_USER: z.string().optional(), - MAILER_SMTP_PASSWORD: z.string().optional(), - // Implicit TLS (port 465). Leave false for the usual STARTTLS-on-25/587 relay. - MAILER_SMTP_SECURE: z.stringbool().default(false), - // Refuse to deliver over a connection the relay never upgraded to TLS. Default - // on, so credentials and recipient addresses cannot silently cross in clear; - // local relays (maildev) that speak no TLS need it explicitly disabled. - MAILER_SMTP_REQUIRE_TLS: z.stringbool().default(true), - // Optional second relay, tried when the primary send fails. - MAILER_FALLBACK_SMTP_HOST: z.string().optional(), - MAILER_FALLBACK_SMTP_PORT: z.coerce.number().optional(), - MAILER_FALLBACK_SMTP_USER: z.string().optional(), - MAILER_FALLBACK_SMTP_PASSWORD: z.string().optional(), - MAILER_FALLBACK_SMTP_SECURE: z.stringbool().default(false), - MAILER_FALLBACK_SMTP_REQUIRE_TLS: z.stringbool().default(true), - // Sender domain for "Chiffrement " - MAILER_DEFAULT_DOMAIN: z.string().min(1), - // The product page email links point to: the encryption interface only exists - // embedded in products, so every notification needs somewhere to send the user. - EMAIL_PRODUCT_URL: z.string().url(), - // Optional path to a JSON file overriding email colours (keys of EmailPalette). - EMAIL_PALETTE_PATH: z.string().optional(), - // Optional error reporting to a Sentry-compatible collector (Sentry, GlitchTip). - // Leaving SENTRY_DSN unset disables reporting entirely and is a fully supported - // deployment mode: nothing in the service depends on a collector existing. - SENTRY_DSN: z.string().optional(), - SENTRY_ENVIRONMENT: z.string().optional(), - // Labels the deployed build in the collector. Defaults to the commit the image - // was built from (SOURCE_COMMIT, set by CI in the Dockerfile), else to the - // /api/version hash of the bundles. A label only: stacks are resolved inside the - // image, so nothing is ever matched against uploaded artifacts. - SENTRY_RELEASE: z.string().optional(), - SOURCE_COMMIT: z.string().optional(), - // Where a security researcher should report a problem with THIS deployment - // (published as /.well-known/security.txt). It belongs to whoever operates the - // instance, not to the code, so nothing is served until the operator sets it: an - // `mailto:` or an `https://` URL. - SECURITY_CONTACT_URL: z.preprocess( - (value) => (value === '' ? undefined : value), - z - .string() - .regex(/^(?:https:\/\/|mailto:)\S+$/) - .optional() - ), - // Optional JSON for the product brand font (BrandFont: family + woff URLs), - // shared by the emails, the Recovery Kit PDF and the interface UI. Unset = each - // surface's generic fallback. - BRAND_FONT: z.string().optional(), -}); +import { envSchema } from '@encryption/src/server/env-schema'; const parsed = envSchema.safeParse(process.env); diff --git a/vitest.config.ts b/vitest.config.ts index d6cf9ca..b7b3c8d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,12 +11,28 @@ export default defineConfig({ }, test: { root: resolve(configDir), - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], environment: 'node', // Node by default, opt-in needed for DOM tests - globalSetup: ['./vitest.global-setup.ts'], coverage: { include: ['src/**/*.{ts,tsx}'], exclude: ['src/**/*.d.ts', 'src/**/*.test.{ts,tsx}', 'src/**/index.ts'], }, + projects: [ + { + extends: true, + test: { + name: 'unit', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + globalSetup: ['./vitest.global-setup.ts'], + }, + }, + { + extends: true, + test: { + name: 'helm', + include: ['deploy/helm/**/*.test.ts'], + testTimeout: 120_000, + }, + }, + ], }, }); From 4fd617473244516b4be0a8b919f7fd448bc69530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Mon, 21 Sep 2026 14:50:51 +0200 Subject: [PATCH 02/47] chore: allow emailing with specific sender address --- .env.model | 2 +- .env.test | 2 +- .env.test.product | 2 +- deploy/helm/__snapshots__/encryption.test.ts.snap | 4 ++-- deploy/helm/encryption.test.ts | 2 ++ .../helm/encryption/ci/existing-secrets-values.yaml | 2 +- deploy/helm/encryption/ci/full-values.yaml | 2 +- deploy/helm/encryption/templates/_helpers.tpl | 4 ++-- deploy/helm/encryption/values.schema.json | 12 ++++++++++-- deploy/helm/encryption/values.yaml | 2 +- deploy/helmfile/env/prod/values.yaml.gotmpl | 2 +- src/server/email/mailer.ts | 2 +- src/server/env-schema.ts | 6 ++++-- src/ui/docs/technical/integration.mdx | 2 +- 14 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.env.model b/.env.model index c54ac59..55c5335 100644 --- a/.env.model +++ b/.env.model @@ -22,7 +22,7 @@ MAILER_FALLBACK_SMTP_USER= MAILER_FALLBACK_SMTP_PASSWORD= MAILER_FALLBACK_SMTP_SECURE=false MAILER_FALLBACK_SMTP_REQUIRE_TLS=true -MAILER_DEFAULT_DOMAIN=numerique.gouv.fr +MAILER_SENDER_ADDRESS=noreply@numerique.gouv.fr EMAIL_PRODUCT_URL=http://localhost:7201 EMAIL_PALETTE_PATH= BRAND_FONT= diff --git a/.env.test b/.env.test index 9a44679..9ca56fc 100644 --- a/.env.test +++ b/.env.test @@ -13,7 +13,7 @@ OIDC_REDIRECT_URI=http://encryption.localhost:7200/auth/callback MAILER_SMTP_HOST=127.0.0.1 MAILER_SMTP_PORT=7208 MAILER_SMTP_REQUIRE_TLS=false -MAILER_DEFAULT_DOMAIN=encryption.localhost +MAILER_SENDER_ADDRESS=noreply@encryption.localhost EMAIL_PRODUCT_URL=http://localhost:7201 SENTRY_DSN=http://11111111111111111111111111111111@localhost:7209/1 SENTRY_ENVIRONMENT=development diff --git a/.env.test.product b/.env.test.product index 3114128..269ec83 100644 --- a/.env.test.product +++ b/.env.test.product @@ -13,5 +13,5 @@ OIDC_ACCEPT_UNVERIFIED_EMAIL=true MAILER_SMTP_HOST=127.0.0.1 MAILER_SMTP_PORT=7208 MAILER_SMTP_REQUIRE_TLS=false -MAILER_DEFAULT_DOMAIN=encryption.localhost +MAILER_SENDER_ADDRESS=noreply@encryption.localhost EMAIL_PRODUCT_URL=http://localhost:3000 diff --git a/deploy/helm/__snapshots__/encryption.test.ts.snap b/deploy/helm/__snapshots__/encryption.test.ts.snap index 16c1d0a..819a0a9 100644 --- a/deploy/helm/__snapshots__/encryption.test.ts.snap +++ b/deploy/helm/__snapshots__/encryption.test.ts.snap @@ -230,8 +230,8 @@ spec: value: "false" - name: MAILER_FALLBACK_SMTP_REQUIRE_TLS value: "false" - - name: MAILER_DEFAULT_DOMAIN - value: "example.org" + - name: MAILER_SENDER_ADDRESS + value: "noreply@example.org" - name: EMAIL_PRODUCT_URL value: "https://docs.example.org" - name: SECURITY_CONTACT_URL diff --git a/deploy/helm/encryption.test.ts b/deploy/helm/encryption.test.ts index 19c830e..dd502df 100644 --- a/deploy/helm/encryption.test.ts +++ b/deploy/helm/encryption.test.ts @@ -206,6 +206,8 @@ describe('the chart', () => { expect(renderError(FULL_VALUES, ['extraEnvs=1'])).toMatch(/extraEnvs.*not allowed|not allowed.*extraEnvs/); expect(renderError(FULL_VALUES, ['config.oidc.issuer=http://insecure.example.org'])).toMatch(/issuer/); expect(renderError(FULL_VALUES, ['hosts.vault='])).toContain('hosts.vault is required'); + expect(renderError(FULL_VALUES, ['config.mailer.senderAddress='])).toContain('config.mailer.senderAddress is required'); + expect(renderError(FULL_VALUES, ['config.mailer.senderAddress=example.org'])).toMatch(/senderAddress/); expect(renderError(FULL_VALUES, ['database.url='])).toContain('database.url or database.existingSecret.name is required'); expect(renderError(EXISTING_SECRETS_VALUES, ['image.digest='])).toContain('image.tag or image.digest is required'); }); diff --git a/deploy/helm/encryption/ci/existing-secrets-values.yaml b/deploy/helm/encryption/ci/existing-secrets-values.yaml index 42b3160..5f97c52 100644 --- a/deploy/helm/encryption/ci/existing-secrets-values.yaml +++ b/deploy/helm/encryption/ci/existing-secrets-values.yaml @@ -17,7 +17,7 @@ config: smtpHost: smtp.example.org existingSecret: name: smtp-credentials - defaultDomain: example.org + senderAddress: noreply@example.org emailProductUrl: https://docs.example.org database: diff --git a/deploy/helm/encryption/ci/full-values.yaml b/deploy/helm/encryption/ci/full-values.yaml index b6172fd..eb30789 100644 --- a/deploy/helm/encryption/ci/full-values.yaml +++ b/deploy/helm/encryption/ci/full-values.yaml @@ -28,7 +28,7 @@ config: smtpHost: smtp-fallback.example.org smtpPort: 25 requireTls: false - defaultDomain: example.org + senderAddress: noreply@example.org emailProductUrl: https://docs.example.org securityContactUrl: mailto:security@example.org diff --git a/deploy/helm/encryption/templates/_helpers.tpl b/deploy/helm/encryption/templates/_helpers.tpl index 86046c5..8847dfd 100644 --- a/deploy/helm/encryption/templates/_helpers.tpl +++ b/deploy/helm/encryption/templates/_helpers.tpl @@ -207,8 +207,8 @@ interface's client. Any other value turns each of the interface's API calls into value: {{ $v.config.mailer.fallback.requireTls | toString | quote }} {{- include "encryption.relayCredentialsEnv" (dict "root" $ "prefix" "MAILER_FALLBACK_SMTP" "relay" $v.config.mailer.fallback) }} {{- end }} -- name: MAILER_DEFAULT_DOMAIN - value: {{ include "encryption.required" (list $v.config.mailer.defaultDomain "config.mailer.defaultDomain") | quote }} +- name: MAILER_SENDER_ADDRESS + value: {{ include "encryption.required" (list $v.config.mailer.senderAddress "config.mailer.senderAddress") | quote }} - name: EMAIL_PRODUCT_URL value: {{ include "encryption.required" (list $v.config.emailProductUrl "config.emailProductUrl") | quote }} {{- if $v.config.brandFont }} diff --git a/deploy/helm/encryption/values.schema.json b/deploy/helm/encryption/values.schema.json index 47c061d..7386876 100644 --- a/deploy/helm/encryption/values.schema.json +++ b/deploy/helm/encryption/values.schema.json @@ -265,8 +265,16 @@ } } }, - "defaultDomain": { - "type": "string" + "senderAddress": { + "anyOf": [ + { + "const": "" + }, + { + "type": "string", + "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" + } + ] } } }, diff --git a/deploy/helm/encryption/values.yaml b/deploy/helm/encryption/values.yaml index 150c299..eddc45d 100644 --- a/deploy/helm/encryption/values.yaml +++ b/deploy/helm/encryption/values.yaml @@ -67,7 +67,7 @@ config: name: '' userKey: MAILER_FALLBACK_SMTP_USER passwordKey: MAILER_FALLBACK_SMTP_PASSWORD - defaultDomain: '' # required, sender domain of the notifications + senderAddress: '' # e.g. noreply@example.org # Where email links send the user (the interface only exists inside a product). Required. emailProductUrl: '' docsEnabled: true diff --git a/deploy/helmfile/env/prod/values.yaml.gotmpl b/deploy/helmfile/env/prod/values.yaml.gotmpl index b724fd9..50ed281 100644 --- a/deploy/helmfile/env/prod/values.yaml.gotmpl +++ b/deploy/helmfile/env/prod/values.yaml.gotmpl @@ -26,7 +26,7 @@ config: smtpPort: 587 existingSecret: name: encryption-smtp - defaultDomain: example.org + senderAddress: noreply@example.org emailProductUrl: https://docs.example.org securityContactUrl: https://vdp.example.org/report diff --git a/src/server/email/mailer.ts b/src/server/email/mailer.ts index 6476a38..c9509b3 100644 --- a/src/server/email/mailer.ts +++ b/src/server/email/mailer.ts @@ -154,7 +154,7 @@ export class Mailer { } export const mailer = new Mailer({ - defaultSender: `Chiffrement `, + defaultSender: `Chiffrement <${env.MAILER_SENDER_ADDRESS}>`, smtp: { host: env.MAILER_SMTP_HOST, port: env.MAILER_SMTP_PORT, diff --git a/src/server/env-schema.ts b/src/server/env-schema.ts index dbb99ce..0c09f08 100644 --- a/src/server/env-schema.ts +++ b/src/server/env-schema.ts @@ -39,8 +39,10 @@ export const envSchema = z.object({ MAILER_FALLBACK_SMTP_PASSWORD: z.string().optional(), MAILER_FALLBACK_SMTP_SECURE: z.stringbool().default(false), MAILER_FALLBACK_SMTP_REQUIRE_TLS: z.stringbool().default(true), - // Sender domain for "Chiffrement " - MAILER_DEFAULT_DOMAIN: z.string().min(1), + // The address notifications are sent from, as "Chiffrement
". Whole, not just a + // domain: a relay only accepts senders it was set up for, and an operator may have to + // use a given mailbox (one per environment, for instance). + MAILER_SENDER_ADDRESS: z.email(), // The product page email links point to: the encryption interface only exists // embedded in products, so every notification needs somewhere to send the user. EMAIL_PRODUCT_URL: z.string().url(), diff --git a/src/ui/docs/technical/integration.mdx b/src/ui/docs/technical/integration.mdx index 607d019..4632806 100644 --- a/src/ui/docs/technical/integration.mdx +++ b/src/ui/docs/technical/integration.mdx @@ -368,7 +368,7 @@ The service lets every user designate a **trusted contact** who can help them re The instance operator does have to configure the notification emails, which are an essential channel (the objection delay only protects the owner if they are told a recovery was requested): - **`EMAIL_PRODUCT_URL`**: the product page the email links point to. The links carry neither a token nor a deep route: once signed in, the pending state resurfaces on its own through the SDK. -- **`MAILER_*`**: the SMTP configuration (`MAILER_SMTP_HOST`, `MAILER_SMTP_PORT`, `MAILER_SMTP_USER`, `MAILER_SMTP_PASSWORD`), the optional fallback transport (`MAILER_FALLBACK_SMTP_*`) and `MAILER_DEFAULT_DOMAIN`. With no SMTP host configured, the server logs the emails instead of sending them. +- **`MAILER_*`**: the SMTP configuration (`MAILER_SMTP_HOST`, `MAILER_SMTP_PORT`, `MAILER_SMTP_USER`, `MAILER_SMTP_PASSWORD`), the optional fallback transport (`MAILER_FALLBACK_SMTP_*`) and `MAILER_SENDER_ADDRESS`, the address notifications are sent from (the relay must accept it). With no SMTP host configured, the server logs the emails instead of sending them. In local development a **maildev** container is provided in `docker-compose.yaml`: the emails sent are readable in a web interface on port **7207** (SMTP on port **7208**), and nothing leaves the machine. From 9f0d527c7c90f301dfb07cf7fa35fbb329ae6003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Mon, 21 Sep 2026 14:56:46 +0200 Subject: [PATCH 03/47] chore(mailer): support locales for the sender name --- src/i18n/en/common.json | 1 + src/i18n/fr/common.json | 1 + src/server/email/emergency.test.ts | 17 ++++++++++++++ src/server/email/emergency.ts | 36 ++++++++++++++++++++---------- src/server/email/mailer.test.ts | 4 ++-- src/server/email/mailer.ts | 16 +++++++++---- 6 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 src/server/email/emergency.test.ts diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index c29457b..eee6574 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -8,6 +8,7 @@ "emails": { "hello": "Hello,", "serviceName": "Encryption", + "senderName": "LaSuite Encryption", "emergencyAccepted": { "subject": "Your trusted contact accepted", "title": "Invitation accepted", diff --git a/src/i18n/fr/common.json b/src/i18n/fr/common.json index 63ab028..fe7d379 100644 --- a/src/i18n/fr/common.json +++ b/src/i18n/fr/common.json @@ -8,6 +8,7 @@ "emails": { "hello": "Bonjour,", "serviceName": "Chiffrement", + "senderName": "LaSuite Chiffrement", "emergencyAccepted": { "subject": "Votre contact de confiance a accepté", "title": "Invitation acceptée", diff --git a/src/server/email/emergency.test.ts b/src/server/email/emergency.test.ts new file mode 100644 index 0000000..b6e2444 --- /dev/null +++ b/src/server/email/emergency.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { senderFor } from '@encryption/src/server/email/emergency'; + +// The module reads the server env at import time +vi.mock('@encryption/src/server/env', () => ({ env: { MAILER_SENDER_ADDRESS: 'chiffrement@example.org' } })); + +describe('senderFor', () => { + it("names the sender in the recipient's language, from the operator's address", () => { + expect(senderFor('fr')).toEqual({ name: 'LaSuite Chiffrement', address: 'chiffrement@example.org' }); + expect(senderFor('en')).toEqual({ name: 'LaSuite Encryption', address: 'chiffrement@example.org' }); + }); + + it('falls back to English for a language the service is not translated in', () => { + expect(senderFor('de').name).toBe('LaSuite Encryption'); + }); +}); diff --git a/src/server/email/emergency.ts b/src/server/email/emergency.ts index 12a39ea..dd9b392 100644 --- a/src/server/email/emergency.ts +++ b/src/server/email/emergency.ts @@ -1,4 +1,5 @@ import { mailer } from '@encryption/src/server/email/mailer'; +import type { SendOptions, Sender } from '@encryption/src/server/email/mailer'; import { EmergencyAcceptedEmail, subject as emergencyAcceptedSubject } from '@encryption/src/server/email/templates/EmergencyAccepted'; import { EmergencyDeclinedEmail, subject as emergencyDeclinedSubject } from '@encryption/src/server/email/templates/EmergencyDeclined'; import { EmergencyDesignatedEmail, subject as emergencyDesignatedSubject } from '@encryption/src/server/email/templates/EmergencyDesignated'; @@ -35,6 +36,17 @@ import { EmergencyVaultRecoveredContactEmail, subject as emergencyVaultRecoveredContactSubject, } from '@encryption/src/server/email/templates/EmergencyVaultRecoveredContact'; +import { env } from '@encryption/src/server/env'; +import { t } from '@encryption/src/server/i18n'; + +/** The sender as the recipient reads it: the service's name in their language, the operator's address. */ +export function senderFor(locale: string): Sender { + return { name: t(locale, 'emails.senderName'), address: env.MAILER_SENDER_ADDRESS }; +} + +function send(locale: string, options: Omit): Promise { + return mailer.send({ ...options, sender: senderFor(locale) }); +} export async function sendEmergencyDesignated(params: { recipient: string; @@ -43,7 +55,7 @@ export async function sendEmergencyDesignated(params: { waitTimeDays: number; productUrl: string; }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyDesignatedSubject(params.locale), emailComponent: EmergencyDesignatedEmail(params), @@ -51,7 +63,7 @@ export async function sendEmergencyDesignated(params: { } export async function sendEmergencyAccepted(params: { recipient: string; locale: string; granteeEmail: string }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyAcceptedSubject(params.locale), emailComponent: EmergencyAcceptedEmail(params), @@ -59,7 +71,7 @@ export async function sendEmergencyAccepted(params: { recipient: string; locale: } export async function sendEmergencyDeclined(params: { recipient: string; locale: string; granteeEmail: string }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyDeclinedSubject(params.locale), emailComponent: EmergencyDeclinedEmail(params), @@ -74,7 +86,7 @@ export async function sendEmergencyRecoveryRequested(params: { deadlineMillis: number; productUrl: string; }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyRecoveryRequestedSubject(params.locale), emailComponent: EmergencyRecoveryRequestedEmail(params), @@ -88,7 +100,7 @@ export async function sendEmergencyRecoveryReminder(params: { daysRemaining: number; productUrl: string; }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyRecoveryReminderSubject(params.locale), emailComponent: EmergencyRecoveryReminderEmail(params), @@ -101,7 +113,7 @@ export async function sendEmergencyRecoveryApprovedGrantor(params: { granteeEmail: string; productUrl: string; }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyRecoveryApprovedGrantorSubject(params.locale), emailComponent: EmergencyRecoveryApprovedGrantorEmail(params), @@ -114,7 +126,7 @@ export async function sendEmergencyRecoveryApprovedContact(params: { grantorEmail: string; productUrl: string; }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyRecoveryApprovedContactSubject(params.locale), emailComponent: EmergencyRecoveryApprovedContactEmail(params), @@ -122,7 +134,7 @@ export async function sendEmergencyRecoveryApprovedContact(params: { } export async function sendEmergencyRecoveryRejected(params: { recipient: string; locale: string; grantorEmail: string }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyRecoveryRejectedSubject(params.locale), emailComponent: EmergencyRecoveryRejectedEmail(params), @@ -130,7 +142,7 @@ export async function sendEmergencyRecoveryRejected(params: { recipient: string; } export async function sendEmergencyRecoveryCancelled(params: { recipient: string; locale: string; granteeEmail: string }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyRecoveryCancelledSubject(params.locale), emailComponent: EmergencyRecoveryCancelledEmail(params), @@ -138,7 +150,7 @@ export async function sendEmergencyRecoveryCancelled(params: { recipient: string } export async function sendEmergencyVaultRecovered(params: { recipient: string; locale: string; granteeEmail: string }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyVaultRecoveredSubject(params.locale), emailComponent: EmergencyVaultRecoveredEmail(params), @@ -146,7 +158,7 @@ export async function sendEmergencyVaultRecovered(params: { recipient: string; l } export async function sendEmergencyVaultRecoveredContact(params: { recipient: string; locale: string; grantorEmail: string }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyVaultRecoveredContactSubject(params.locale), emailComponent: EmergencyVaultRecoveredContactEmail(params), @@ -154,7 +166,7 @@ export async function sendEmergencyVaultRecoveredContact(params: { recipient: st } export async function sendEmergencyRevoked(params: { recipient: string; locale: string; grantorEmail: string }): Promise { - await mailer.send({ + await send(params.locale, { recipients: [params.recipient], subject: emergencyRevokedSubject(params.locale), emailComponent: EmergencyRevokedEmail(params), diff --git a/src/server/email/mailer.test.ts b/src/server/email/mailer.test.ts index 481f44e..d7a8c0f 100644 --- a/src/server/email/mailer.test.ts +++ b/src/server/email/mailer.test.ts @@ -7,7 +7,7 @@ import { EmergencyAcceptedEmail } from '@encryption/src/server/email/templates/E // The module singleton reads the server env at import time; tests build their own instances vi.mock('@encryption/src/server/env', () => ({ env: {} })); -const defaultSender = 'Chiffrement '; +const defaultSender = { name: 'LaSuite Chiffrement', address: 'noreply@example.com' }; const smtpSettings = { host: 'primary.example.com', port: 25, user: '', password: '' }; const fallbackSmtpSettings = { host: 'fallback.example.com', port: 25, user: '', password: '' }; @@ -89,7 +89,7 @@ describe('Mailer', () => { // A non-ASCII subject must cross the wire encoded, never raw. expect(sent).toMatch(/Subject: =\?UTF-8\?/); expect(sent).toContain('To: grantor@example.com'); - expect(sent).toContain('From: Chiffrement '); + expect(sent).toContain('From: LaSuite Chiffrement '); }); it('throws rather than silently dropping the email when no SMTP host is configured', async () => { diff --git a/src/server/email/mailer.ts b/src/server/email/mailer.ts index c9509b3..c3976e8 100644 --- a/src/server/email/mailer.ts +++ b/src/server/email/mailer.ts @@ -9,6 +9,7 @@ import { setEmailAssetBaseUrl } from '@encryption/src/server/email/assets'; import { convertHtmlEmailToText } from '@encryption/src/server/email/helpers'; import { applyEmailPaletteOverride } from '@encryption/src/server/email/palette'; import { env } from '@encryption/src/server/env'; +import { t } from '@encryption/src/server/i18n'; import { parseBrandFont, setServerBrandFont } from '@encryption/src/shared/brand-font'; // Emails reference the logo by absolute URL (mail clients cannot resolve a @@ -44,15 +45,21 @@ export interface EmailServerSettings { export type CreateTransportFactory = (settings: EmailServerSettings) => Transporter; export interface MailerOptions { - defaultSender: string; + defaultSender: Sender; smtp?: EmailServerSettings; fallbackSmtp?: EmailServerSettings; // Injectable so tests can substitute nodemailer's jsonTransport for real SMTP connections createTransport?: CreateTransportFactory; } +/** A pair rather than a "Name
" string: nodemailer then quotes the name itself. */ +export interface Sender { + name: string; + address: string; +} + export interface SendOptions { - sender?: string; + sender?: Sender; replyTo?: string; recipients: string[]; subject: string; @@ -73,7 +80,7 @@ function defaultCreateTransport(settings: EmailServerSettings): Transporter { export class Mailer { protected transporter: Transporter | null = null; protected fallbackTransporter: Transporter | null = null; - protected defaultSender: string; + protected defaultSender: Sender; constructor(options: MailerOptions) { this.defaultSender = options.defaultSender; @@ -154,7 +161,8 @@ export class Mailer { } export const mailer = new Mailer({ - defaultSender: `Chiffrement <${env.MAILER_SENDER_ADDRESS}>`, + // Only a fallback: every notification names its sender in the recipient's language. + defaultSender: { name: t('en', 'emails.senderName'), address: env.MAILER_SENDER_ADDRESS }, smtp: { host: env.MAILER_SMTP_HOST, port: env.MAILER_SMTP_PORT, From b2d7de5249c315d2eb1cea6f0529f7471c28e513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Mon, 21 Sep 2026 17:45:04 +0200 Subject: [PATCH 04/47] chore(deploy): add db migration files to make the service running on real deployment --- .../20260921153450_baseline/migration.sql | 320 ++++++++++++++++++ src/prisma/migrations/migration_lock.toml | 3 + 2 files changed, 323 insertions(+) create mode 100644 src/prisma/migrations/20260921153450_baseline/migration.sql create mode 100644 src/prisma/migrations/migration_lock.toml diff --git a/src/prisma/migrations/20260921153450_baseline/migration.sql b/src/prisma/migrations/20260921153450_baseline/migration.sql new file mode 100644 index 0000000..37f3cab --- /dev/null +++ b/src/prisma/migrations/20260921153450_baseline/migration.sql @@ -0,0 +1,320 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "encryption"; + +-- CreateEnum +CREATE TYPE "encryption"."SignatureAlgo" AS ENUM ('ed25519'); + +-- CreateEnum +CREATE TYPE "encryption"."EncryptionAlgo" AS ENUM ('x-wing'); + +-- CreateEnum +CREATE TYPE "encryption"."VaultItemType" AS ENUM ('identity', 'encryptionKey', 'tofu', 'active'); + +-- CreateEnum +CREATE TYPE "encryption"."VaultCredentialType" AS ENUM ('primary', 'emergency'); + +-- CreateEnum +CREATE TYPE "encryption"."EmergencyAccessStatus" AS ENUM ('invited', 'confirmed', 'recovery_requested', 'recovery_approved'); + +-- CreateTable +CREATE TABLE "encryption"."users" ( + "id" UUID NOT NULL, + "email" TEXT NOT NULL, + "language" TEXT NOT NULL DEFAULT 'en', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."oidc_accounts" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP(3), + + CONSTRAINT "oidc_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."identities" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "signature_public_key" BYTEA NOT NULL, + "algo" "encryption"."SignatureAlgo" NOT NULL DEFAULT 'ed25519', + "generation" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP(3), + "previous_identity_id" UUID, + "continuity_signature" BYTEA, + + CONSTRAINT "identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."encryption_keys" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "identity_id" UUID NOT NULL, + "encryption_public_key" BYTEA NOT NULL, + "algo" "encryption"."EncryptionAlgo" NOT NULL DEFAULT 'x-wing', + "key_binding_signature" BYTEA NOT NULL, + "version" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP(3), + + CONSTRAINT "encryption_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."key_possession_challenges" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "encryption_public_key" BYTEA NOT NULL, + "signature_public_key" BYTEA NOT NULL, + "key_binding_signature" BYTEA NOT NULL, + "version" INTEGER NOT NULL, + "signed_created_at" TIMESTAMP(3) NOT NULL, + "expected_hmac" BYTEA NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "key_possession_challenges_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."vault_items" ( + "id" UUID NOT NULL, + "vault_id" UUID NOT NULL, + "item_id" TEXT NOT NULL, + "type" "encryption"."VaultItemType" NOT NULL, + "ciphertext" TEXT NOT NULL, + "revision_date" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "vault_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."vault_meta" ( + "vault_id" UUID NOT NULL, + "account_revision" INTEGER NOT NULL DEFAULT 0, + "manifest" TEXT NOT NULL, + "manifest_sig" BYTEA NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "vault_meta_pkey" PRIMARY KEY ("vault_id") +); + +-- CreateTable +CREATE TABLE "encryption"."vault_keyring" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "identity_id" UUID NOT NULL, + "disabled_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "vault_keyring_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."vault_credentials" ( + "id" UUID NOT NULL, + "vault_id" UUID NOT NULL, + "type" "encryption"."VaultCredentialType" NOT NULL, + "wrapped_vrk" TEXT NOT NULL, + "auth_public_key" BYTEA NOT NULL, + "auth_pub_sig" BYTEA NOT NULL, + "kdf_ops" INTEGER NOT NULL, + "kdf_mem" INTEGER NOT NULL, + "lang" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "vault_credentials_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."emergency_access" ( + "id" UUID NOT NULL, + "grantor_user_id" UUID NOT NULL, + "grantee_user_id" UUID NOT NULL, + "status" "encryption"."EmergencyAccessStatus" NOT NULL, + "wait_time_days" INTEGER NOT NULL, + "credential_id" UUID NOT NULL, + "wrapped_phrase_for_grantee" TEXT NOT NULL, + "grantee_identity_id" UUID NOT NULL, + "grantee_key_version" INTEGER NOT NULL, + "escrow_signature" BYTEA NOT NULL, + "escrow_created_at" TIMESTAMP(3) NOT NULL, + "recovery_requested_at" TIMESTAMP(3), + "last_notified_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "emergency_access_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."vault_challenges" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "nonce" BYTEA NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "vault_challenges_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encryption"."vault_approvals" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "request_id" TEXT NOT NULL, + "device_public_key" TEXT NOT NULL, + "wrapped_vrk_for_device" TEXT, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "vault_approvals_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "oidc_accounts_user_id_idx" ON "encryption"."oidc_accounts"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "oidc_accounts_issuer_subject_key" ON "encryption"."oidc_accounts"("issuer", "subject"); + +-- CreateIndex +CREATE UNIQUE INDEX "identities_signature_public_key_key" ON "encryption"."identities"("signature_public_key"); + +-- CreateIndex +CREATE INDEX "identities_user_id_disabled_at_idx" ON "encryption"."identities"("user_id", "disabled_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "identities_user_id_generation_key" ON "encryption"."identities"("user_id", "generation"); + +-- CreateIndex +CREATE UNIQUE INDEX "encryption_keys_encryption_public_key_key" ON "encryption"."encryption_keys"("encryption_public_key"); + +-- CreateIndex +CREATE INDEX "encryption_keys_user_id_disabled_at_idx" ON "encryption"."encryption_keys"("user_id", "disabled_at"); + +-- CreateIndex +CREATE INDEX "encryption_keys_user_id_created_at_idx" ON "encryption"."encryption_keys"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX "encryption_keys_identity_id_idx" ON "encryption"."encryption_keys"("identity_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "encryption_keys_user_id_version_key" ON "encryption"."encryption_keys"("user_id", "version"); + +-- CreateIndex +CREATE INDEX "key_possession_challenges_user_id_idx" ON "encryption"."key_possession_challenges"("user_id"); + +-- CreateIndex +CREATE INDEX "key_possession_challenges_expires_at_idx" ON "encryption"."key_possession_challenges"("expires_at"); + +-- CreateIndex +CREATE INDEX "vault_items_vault_id_idx" ON "encryption"."vault_items"("vault_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "vault_items_vault_id_item_id_key" ON "encryption"."vault_items"("vault_id", "item_id"); + +-- CreateIndex +CREATE INDEX "vault_keyring_user_id_idx" ON "encryption"."vault_keyring"("user_id"); + +-- CreateIndex +CREATE INDEX "vault_keyring_user_id_disabled_at_idx" ON "encryption"."vault_keyring"("user_id", "disabled_at"); + +-- CreateIndex +CREATE INDEX "vault_credentials_vault_id_idx" ON "encryption"."vault_credentials"("vault_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "emergency_access_credential_id_key" ON "encryption"."emergency_access"("credential_id"); + +-- CreateIndex +CREATE INDEX "emergency_access_grantee_user_id_idx" ON "encryption"."emergency_access"("grantee_user_id"); + +-- CreateIndex +CREATE INDEX "emergency_access_grantee_identity_id_idx" ON "encryption"."emergency_access"("grantee_identity_id"); + +-- CreateIndex +CREATE INDEX "emergency_access_status_recovery_requested_at_idx" ON "encryption"."emergency_access"("status", "recovery_requested_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "emergency_access_grantor_user_id_grantee_user_id_key" ON "encryption"."emergency_access"("grantor_user_id", "grantee_user_id"); + +-- CreateIndex +CREATE INDEX "vault_challenges_user_id_idx" ON "encryption"."vault_challenges"("user_id"); + +-- CreateIndex +CREATE INDEX "vault_challenges_expires_at_idx" ON "encryption"."vault_challenges"("expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "vault_approvals_request_id_key" ON "encryption"."vault_approvals"("request_id"); + +-- CreateIndex +CREATE INDEX "vault_approvals_user_id_idx" ON "encryption"."vault_approvals"("user_id"); + +-- CreateIndex +CREATE INDEX "vault_approvals_expires_at_idx" ON "encryption"."vault_approvals"("expires_at"); + +-- AddForeignKey +ALTER TABLE "encryption"."oidc_accounts" ADD CONSTRAINT "oidc_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."identities" ADD CONSTRAINT "identities_previous_identity_id_fkey" FOREIGN KEY ("previous_identity_id") REFERENCES "encryption"."identities"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."identities" ADD CONSTRAINT "identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."encryption_keys" ADD CONSTRAINT "encryption_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."encryption_keys" ADD CONSTRAINT "encryption_keys_identity_id_fkey" FOREIGN KEY ("identity_id") REFERENCES "encryption"."identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."key_possession_challenges" ADD CONSTRAINT "key_possession_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."vault_items" ADD CONSTRAINT "vault_items_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "encryption"."vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."vault_meta" ADD CONSTRAINT "vault_meta_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "encryption"."vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."vault_keyring" ADD CONSTRAINT "vault_keyring_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."vault_keyring" ADD CONSTRAINT "vault_keyring_identity_id_fkey" FOREIGN KEY ("identity_id") REFERENCES "encryption"."identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."vault_credentials" ADD CONSTRAINT "vault_credentials_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "encryption"."vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."emergency_access" ADD CONSTRAINT "emergency_access_grantor_user_id_fkey" FOREIGN KEY ("grantor_user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."emergency_access" ADD CONSTRAINT "emergency_access_grantee_user_id_fkey" FOREIGN KEY ("grantee_user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."emergency_access" ADD CONSTRAINT "emergency_access_grantee_identity_id_fkey" FOREIGN KEY ("grantee_identity_id") REFERENCES "encryption"."identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."emergency_access" ADD CONSTRAINT "emergency_access_credential_id_fkey" FOREIGN KEY ("credential_id") REFERENCES "encryption"."vault_credentials"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."vault_challenges" ADD CONSTRAINT "vault_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encryption"."vault_approvals" ADD CONSTRAINT "vault_approvals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/src/prisma/migrations/migration_lock.toml b/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" From 1d29f971c797022d139c1cbe05c9661375ceb53a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Mon, 21 Sep 2026 20:48:59 +0200 Subject: [PATCH 05/47] fix(routing): scoped fastify plugins were not able to serve assets --- src/server/plugins/infra-paths.ts | 6 ++ src/server/plugins/static-serving.test.ts | 90 +++++++++++++++++++++++ src/server/plugins/static-ui.ts | 17 ++--- src/server/plugins/static-vault.ts | 8 +- src/server/plugins/vite-dev.ts | 6 +- 5 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 src/server/plugins/infra-paths.ts create mode 100644 src/server/plugins/static-serving.test.ts diff --git a/src/server/plugins/infra-paths.ts b/src/server/plugins/infra-paths.ts new file mode 100644 index 0000000..f385f31 --- /dev/null +++ b/src/server/plugins/infra-paths.ts @@ -0,0 +1,6 @@ +// Infrastructure paths Fastify owns on EVERY host, including the vault and UI hosts whose +// remaining traffic belongs to the static files (production) or to Vite (development). +// Both of those intercept requests before routing, so a path missing here is shadowed: +// it 404s under Vite, and the interface answers it with its HTML page in production. +// Must stay in sync with the matching routes in server.ts. +export const FASTIFY_INFRA_PATHS = new Set(['/health', '/robots.txt', '/favicon.ico', '/.well-known/security.txt']); diff --git a/src/server/plugins/static-serving.test.ts b/src/server/plugins/static-serving.test.ts new file mode 100644 index 0000000..991c7d0 --- /dev/null +++ b/src/server/plugins/static-serving.test.ts @@ -0,0 +1,90 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@encryption/src/server/env', () => ({ + env: { + UI_HOST: 'encryption.example.org', + VAULT_HOST: 'data.encryption.example.org', + UI_URL: 'https://encryption.example.org', + VAULT_URL: 'https://data.encryption.example.org', + ALLOWED_FRAME_ANCESTORS: 'https://docs.example.org', + OIDC_ISSUER: 'https://auth.example.org', + OIDC_CLIENT_ID: 'encryption', + OIDC_REDIRECT_URI: 'https://encryption.example.org/auth/callback', + DOCS_ENABLED: true, + }, +})); + +// Registered the way server.ts does it, on top of a route of the server's own. The pages +// and the vault files are not routes: they are answered by hooks, and a hook only runs +// for a path that matches no route when its plugin is not encapsulated. That is invisible +// in development (Vite serves) and to a health check, so it is asserted here. +describe('production static serving', () => { + let app: FastifyInstance; + + beforeEach(async () => { + const root = mkdtempSync(join(tmpdir(), 'dist-')); + + mkdirSync(join(root, 'dist/ui/assets'), { recursive: true }); + mkdirSync(join(root, 'dist/vault'), { recursive: true }); + mkdirSync(join(root, 'dist/client'), { recursive: true }); + writeFileSync(join(root, 'dist/ui/interface.html'), 'interface'); + writeFileSync(join(root, 'dist/vault/bridge.html'), 'bridge'); + writeFileSync(join(root, 'dist/vault/vault.js'), 'vault'); + writeFileSync(join(root, 'dist/client/client.js'), 'client'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const { staticUiPlugin } = await import('@encryption/src/server/plugins/static-ui'); + const { staticVaultPlugin } = await import('@encryption/src/server/plugins/static-vault'); + + app = Fastify(); + app.get('/health', async () => ({ status: 'ok' })); + app.register(staticVaultPlugin); + app.register(staticUiPlugin); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + vi.restoreAllMocks(); + }); + + function get(host: string, url: string) { + return app.inject({ method: 'GET', url, headers: { host } }); + } + + it('serves the interface page for any page path of the interface host', async () => { + for (const url of ['/', '/login', '/auth/callback?code=abc']) { + const response = await get('encryption.example.org', url); + + expect(response.statusCode, url).toBe(200); + expect(response.headers['content-type'], url).toContain('text/html'); + expect(response.body, url).toContain('interface'); + } + }); + + it('serves the vault files on the vault host only', async () => { + expect((await get('data.encryption.example.org', '/client.js')).body).toBe('client'); + expect((await get('data.encryption.example.org', '/vault.js')).body).toBe('vault'); + expect((await get('data.encryption.example.org', '/bridge.html')).statusCode).toBe(200); + + expect((await get('encryption.example.org', '/client.js')).body).not.toBe('client'); + expect((await get('another.example.org', '/client.js')).statusCode).toBe(404); + }); + + it("leaves the server's own paths alone, on every host", async () => { + for (const host of ['encryption.example.org', 'data.encryption.example.org', '10.0.0.1:7200']) { + const response = await get(host, '/health'); + + expect(response.json(), host).toEqual({ status: 'ok' }); + } + }); + + it('serves nothing to a host it does not know', async () => { + expect((await get('another.example.org', '/')).statusCode).toBe(404); + expect((await get('data.encryption.example.org', '/')).statusCode).toBe(404); + }); +}); diff --git a/src/server/plugins/static-ui.ts b/src/server/plugins/static-ui.ts index 5c04497..36aa138 100644 --- a/src/server/plugins/static-ui.ts +++ b/src/server/plugins/static-ui.ts @@ -1,9 +1,11 @@ import fastifyStatic from '@fastify/static'; import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { env } from '@encryption/src/server/env'; +import { FASTIFY_INFRA_PATHS } from '@encryption/src/server/plugins/infra-paths'; import { parseBrandFont } from '@encryption/src/shared/brand-font'; import { buildRuntimeConfigBlock } from '@encryption/src/shared/runtime-config'; @@ -35,7 +37,10 @@ export function isServableAsset(pathName: string): boolean { return !pathName.endsWith('.map'); } -export async function staticUiPlugin(app: FastifyInstance): Promise { +// Wrapped with fastify-plugin to break encapsulation: the hook below answers paths that are +// no route at all (`/`, `/login`...), and a hook private to a plugin only ever runs for the +// routes declared inside that plugin. Unwrapped, every page is a 404. +export const staticUiPlugin = fp(async (app: FastifyInstance): Promise => { const distDir = resolve(process.cwd(), 'dist/ui'); if (!existsSync(distDir)) { @@ -77,16 +82,10 @@ export async function staticUiPlugin(app: FastifyInstance): Promise { const path = request.url.split('?')[0]; // Skip asset and API requests - if ( - path.startsWith('/assets/') || - path.startsWith('/public-assets/') || - path === '/robots.txt' || - path === '/.well-known/security.txt' || - path.startsWith('/api/') - ) { + if (FASTIFY_INFRA_PATHS.has(path) || path.startsWith('/assets/') || path.startsWith('/public-assets/') || path.startsWith('/api/')) { return; } reply.type('text/html').send(interfaceHtml); }); -} +}); diff --git a/src/server/plugins/static-vault.ts b/src/server/plugins/static-vault.ts index 3c91c0c..4e89238 100644 --- a/src/server/plugins/static-vault.ts +++ b/src/server/plugins/static-vault.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -17,7 +18,10 @@ function buildVaultConfigBlock(): string { return buildRuntimeConfigBlock(config); } -export async function staticVaultPlugin(app: FastifyInstance): Promise { +// Wrapped with fastify-plugin to break encapsulation: the hook below serves files that are +// no route (`/vault.js`, `/client.js`...), and a hook private to a plugin only ever runs +// for the routes declared inside that plugin. Unwrapped, every file is a 404. +export const staticVaultPlugin = fp(async (app: FastifyInstance): Promise => { const vaultDir = resolve(process.cwd(), 'dist/vault'); const clientDir = resolve(process.cwd(), 'dist/client'); @@ -68,4 +72,4 @@ export async function staticVaultPlugin(app: FastifyInstance): Promise { reply.type(file.type).header('Access-Control-Allow-Origin', '*').send(file.content); } }); -} +}); diff --git a/src/server/plugins/vite-dev.ts b/src/server/plugins/vite-dev.ts index 606243d..97fd414 100644 --- a/src/server/plugins/vite-dev.ts +++ b/src/server/plugins/vite-dev.ts @@ -6,6 +6,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { env } from '@encryption/src/server/env'; +import { FASTIFY_INFRA_PATHS } from '@encryption/src/server/plugins/infra-paths'; /** * Development-only plugin that embeds Vault and UI Vite dev servers @@ -22,11 +23,6 @@ import { env } from '@encryption/src/server/env'; * Wrapped with fastify-plugin to break encapsulation — the middleware * must apply at the root scope so it intercepts requests before Fastify routes. */ -// Infrastructure paths Fastify owns on EVERY host, including the vault and UI -// hosts whose remaining traffic belongs to Vite. Must stay in sync with the -// matching routes in server.ts: a path missing here is shadowed by Vite in dev -// and 404s, while working fine in production. -const FASTIFY_INFRA_PATHS = new Set(['/health', '/robots.txt', '/favicon.ico', '/.well-known/security.txt']); export const viteDevPlugin = fp(async (app: FastifyInstance): Promise => { const { createServer: createViteServer } = await import('vite'); From 11b45605d6f76c742a46cdf36f7cd0c4c5b7eeb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Mon, 21 Sep 2026 21:35:38 +0200 Subject: [PATCH 06/47] chore(monitoring): split health endpoint to check also database state and pages to be served --- .../__snapshots__/encryption.test.ts.snap | 15 +- deploy/helm/encryption/README.md | 2 +- .../helm/encryption/templates/deployment.yaml | 12 +- .../encryption/templates/tests/health.yaml | 2 +- deploy/helm/encryption/values.yaml | 5 + src/server/plugins/infra-paths.ts | 9 +- src/server/routes/health.test.ts | 121 +++++++++++++++++ src/server/routes/health.ts | 128 ++++++++++++++++++ src/server/server.ts | 10 +- 9 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 src/server/routes/health.test.ts create mode 100644 src/server/routes/health.ts diff --git a/deploy/helm/__snapshots__/encryption.test.ts.snap b/deploy/helm/__snapshots__/encryption.test.ts.snap index 819a0a9..ab2af44 100644 --- a/deploy/helm/__snapshots__/encryption.test.ts.snap +++ b/deploy/helm/__snapshots__/encryption.test.ts.snap @@ -250,18 +250,27 @@ spec: value: "production" - name: EMAIL_PALETTE_PATH value: /config/palette.json + # One level per probe (src/server/routes/health.ts). Startup also queries the + # database: a pod with wrong database settings, or an unmigrated database, never + # becomes ready, so a rollout keeps the pods that work. startupProbe: httpGet: - path: /health + path: /health/startup port: http failureThreshold: 30 periodSeconds: 2 + timeoutSeconds: 4 + # Readiness asks the server for its own interface page, a vault file and an API + # route. Not the database: the vault decrypts locally and must stay reachable + # while the database is down. readinessProbe: httpGet: - path: /health + path: /health/ready port: http periodSeconds: 10 timeoutSeconds: 2 + # Liveness is only "the process answers": anything more turns an outage of that + # thing into a restart loop. livenessProbe: httpGet: path: /health @@ -416,7 +425,7 @@ spec: readOnlyRootFilesystem: true env: - name: HEALTH_URL - value: "http://enc-encryption:80/health" + value: "http://enc-encryption:80/health/startup" args: - -e - | diff --git a/deploy/helm/encryption/README.md b/deploy/helm/encryption/README.md index 472383f..d0193ca 100644 --- a/deploy/helm/encryption/README.md +++ b/deploy/helm/encryption/README.md @@ -85,4 +85,4 @@ Two layers, one fast and one real. `npm run test:helm` (helm and kubeconform on the PATH: `brew install helm kubeconform`). It lints, validates the values schema, renders both examples, checks every object against the Kubernetes API schemas, asserts on the rendered objects, and compares the full render to a committed snapshot so any change shows in review. The one assertion to keep in mind: the Deployment must pass every variable `src/server/env-schema.ts` requires, read from that schema, so adding a required variable to the server without adding it to the chart fails the build. Another one covers the migration hook: whatever the Job reads must be a hook that runs before it, or something the chart does not create. In CI this runs on pull requests, on `main` and on chart tags, never on application tags. -`deploy/helm/smoke-k3d.sh` (docker, k3d, kubectl, helm) creates a throwaway k3s cluster with a PostgreSQL set up by the scripts of `deploy/postgres/`, imports a locally built image, installs the chart into a namespace enforcing the `restricted` Pod Security level, checks that the migration hook wrote its journal as the migrator role and left none of its objects behind, runs `helm test` (a pod inside the cluster calling `/health` through the Service), then rotates the migrator's password, upgrades in place and checks all of it again. This is what a render cannot show: admission, probes on the real image, the hook ordering against a real database, the rolling update. Local only, a couple of minutes, `KEEP=1` leaves the cluster up for `kubectl`. +`deploy/helm/smoke-k3d.sh` (docker, k3d, kubectl, helm) creates a throwaway k3s cluster with a PostgreSQL set up by the scripts of `deploy/postgres/`, imports a locally built image, installs the chart into a namespace enforcing the `restricted` Pod Security level, checks that the migration hook wrote its journal as the migrator role and left none of its objects behind, runs `helm test` (a pod inside the cluster calling `/health/startup` through the Service, which also proves the pages are served and the migrated database answers), then rotates the migrator's password, upgrades in place and checks all of it again. This is what a render cannot show: admission, probes on the real image, the hook ordering against a real database, the rolling update. Local only, a couple of minutes, `KEEP=1` leaves the cluster up for `kubectl`. diff --git a/deploy/helm/encryption/templates/deployment.yaml b/deploy/helm/encryption/templates/deployment.yaml index ceff948..345def9 100644 --- a/deploy/helm/encryption/templates/deployment.yaml +++ b/deploy/helm/encryption/templates/deployment.yaml @@ -69,16 +69,24 @@ spec: envFrom: {{- toYaml . | nindent 12 }} {{- end }} + # One level per probe (src/server/routes/health.ts). Startup also queries the + # database: a pod with wrong database settings, or an unmigrated database, never + # becomes ready, so a rollout keeps the pods that work. startupProbe: httpGet: - path: /health + path: /health/startup port: http {{- toYaml .Values.probes.startup | nindent 12 }} + # Readiness asks the server for its own interface page, a vault file and an API + # route. Not the database: the vault decrypts locally and must stay reachable + # while the database is down. readinessProbe: httpGet: - path: /health + path: /health/ready port: http {{- toYaml .Values.probes.readiness | nindent 12 }} + # Liveness is only "the process answers": anything more turns an outage of that + # thing into a restart loop. livenessProbe: httpGet: path: /health diff --git a/deploy/helm/encryption/templates/tests/health.yaml b/deploy/helm/encryption/templates/tests/health.yaml index feffd37..2bc8e20 100644 --- a/deploy/helm/encryption/templates/tests/health.yaml +++ b/deploy/helm/encryption/templates/tests/health.yaml @@ -38,7 +38,7 @@ spec: {{- end }} env: - name: HEALTH_URL - value: {{ printf "http://%s:%d/health" (include "encryption.fullname" .) (int .Values.service.port) | quote }} + value: {{ printf "http://%s:%d/health/startup" (include "encryption.fullname" .) (int .Values.service.port) | quote }} args: - -e - | diff --git a/deploy/helm/encryption/values.yaml b/deploy/helm/encryption/values.yaml index eddc45d..8fac104 100644 --- a/deploy/helm/encryption/values.yaml +++ b/deploy/helm/encryption/values.yaml @@ -173,9 +173,14 @@ resources: # Node sizes its heap from this limit (--max-old-space-size-percentage in the image). memory: 1Gi +# The three probes call three levels of the server's own check: /health/startup (serves its +# pages, its API, and reaches a migrated database), /health/ready (the same without the +# database) and /health (the process answers). probes: startup: periodSeconds: 2 + # The first database round trip opens a TLS connection: more than the default second. + timeoutSeconds: 4 failureThreshold: 30 readiness: periodSeconds: 10 diff --git a/src/server/plugins/infra-paths.ts b/src/server/plugins/infra-paths.ts index f385f31..2c72d38 100644 --- a/src/server/plugins/infra-paths.ts +++ b/src/server/plugins/infra-paths.ts @@ -3,4 +3,11 @@ // Both of those intercept requests before routing, so a path missing here is shadowed: // it 404s under Vite, and the interface answers it with its HTML page in production. // Must stay in sync with the matching routes in server.ts. -export const FASTIFY_INFRA_PATHS = new Set(['/health', '/robots.txt', '/favicon.ico', '/.well-known/security.txt']); +export const FASTIFY_INFRA_PATHS = new Set([ + '/health', + '/health/ready', + '/health/startup', + '/robots.txt', + '/favicon.ico', + '/.well-known/security.txt', +]); diff --git a/src/server/routes/health.test.ts b/src/server/routes/health.test.ts new file mode 100644 index 0000000..d2fd919 --- /dev/null +++ b/src/server/routes/health.test.ts @@ -0,0 +1,121 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const findFirst = vi.fn(); + +vi.mock('@encryption/src/prisma/client', () => ({ prisma: { user: { findFirst: (...args: unknown[]) => findFirst(...args) } } })); +vi.mock('@encryption/src/server/env', () => ({ + env: { UI_HOST: 'encryption.example.org', VAULT_HOST: 'data.encryption.example.org' }, +})); + +interface Broken { + interfacePage?: boolean; + vaultFile?: boolean; + api?: boolean; +} + +// The three things the server serves, reduced to what the checks look at: a host, a path, +// a status and a content type. `broken` removes one, the way a bad build or a bad +// registration would. +async function serverWith(broken: Broken = {}): Promise { + const { healthRoute } = await import('@encryption/src/server/routes/health'); + const app = Fastify(); + + app.addHook('onRequest', async (request, reply) => { + const path = request.url.split('?')[0]; + + if (request.host === 'encryption.example.org' && path === '/' && !broken.interfacePage) { + return reply.type('text/html').send(''); + } + + if (request.host === 'data.encryption.example.org' && path === '/vault.js' && !broken.vaultFile) { + return reply.type('application/javascript').send('vault'); + } + }); + + if (!broken.api) app.get('/api/version', async () => ({ version: 'test' })); + + app.register(healthRoute); + await app.ready(); + + return app; +} + +describe('health routes', () => { + let app: FastifyInstance; + + afterEach(async () => { + await app.close(); + vi.resetModules(); + findFirst.mockReset(); + }); + + it('reports ready when the interface, the vault and the API are all served', async () => { + app = await serverWith(); + + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ status: 'ok', checks: { interface: 'ok', vault: 'ok', api: 'ok' } }); + }); + + it.each([ + ['interfacePage', 'interface'], + ['vaultFile', 'vault'], + ['api', 'api'], + ] as const)('is not ready, and says why, when %s is not served', async (what, check) => { + app = await serverWith({ [what]: true }); + + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + + expect(response.statusCode).toBe(503); + expect(response.json().status).toBe('failing'); + expect(response.json().checks[check]).toBe('failing'); + }); + + it('stays alive whatever is broken: liveness must never depend on the rest', async () => { + findFirst.mockRejectedValue(new Error('User was denied access on the database')); + app = await serverWith({ interfacePage: true, vaultFile: true, api: true }); + + expect((await app.inject({ method: 'GET', url: '/health' })).json()).toEqual({ status: 'ok' }); + }); + + it('refuses to start on a database that refuses it, or was never migrated', async () => { + findFirst.mockRejectedValue(new Error('The table `encryption.users` does not exist')); + app = await serverWith(); + + const response = await app.inject({ method: 'GET', url: '/health/startup' }); + + expect(response.statusCode).toBe(503); + expect(response.json().checks).toEqual({ interface: 'ok', vault: 'ok', api: 'ok', database: 'failing' }); + // The reason is for the logs, never for the public body. + expect(response.body).not.toContain('does not exist'); + }); + + it('starts once the database answers on a real table', async () => { + findFirst.mockResolvedValue(null); + app = await serverWith(); + + const response = await app.inject({ method: 'GET', url: '/health/startup' }); + + expect(response.statusCode).toBe(200); + expect(findFirst).toHaveBeenCalledWith({ select: { id: true } }); + }); + + it('keeps a database outage out of readiness: the vault must stay reachable', async () => { + findFirst.mockRejectedValue(new Error('connection refused')); + app = await serverWith(); + + expect((await app.inject({ method: 'GET', url: '/health/ready' })).statusCode).toBe(200); + expect(findFirst).not.toHaveBeenCalled(); + }); + + it('evaluates once per window, however many probes ask', async () => { + findFirst.mockResolvedValue(null); + app = await serverWith(); + + await Promise.all([1, 2, 3, 4, 5].map(() => app.inject({ method: 'GET', url: '/health/startup' }))); + + expect(findFirst).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts new file mode 100644 index 0000000..9a90d2e --- /dev/null +++ b/src/server/routes/health.ts @@ -0,0 +1,128 @@ +import type { FastifyInstance } from 'fastify'; + +import { prisma } from '@encryption/src/prisma/client'; +import { env } from '@encryption/src/server/env'; + +/** + * Three levels, one per Kubernetes probe, because each failure calls for a different + * reaction: + * + * - `/health`: the process answers. Liveness. Depending on anything else here would turn + * an outage of that thing into a restart loop of every pod, which repairs nothing. + * - `/health/ready`: the server serves what it exists to serve. It asks ITSELF, through + * the same hooks and routing as a real request, for the interface page, a vault file and + * an API route that reads no database. "The process answers" says nothing about those: + * a registration mistake once made every page a 404 while `/health` stayed green. + * - `/health/startup`: the above, plus a query on a real table. Only while a pod boots, so + * a pod whose database settings are wrong (refused connection, missing migration) never + * becomes ready and a rollout keeps the working pods. Not part of readiness on purpose: + * the vault decrypts locally, and must stay reachable while the database is down. + * + * The bodies say which check fails and nothing more: these endpoints are public. + */ + +// light-my-request puts this on the injected socket. Not an IP address, so no real +// connection can carry it, which lets the access log skip the self-requests safely. +export const SELF_CHECK_REMOTE_ADDRESS = 'self-check'; + +const CACHE_MS = 5_000; +const DATABASE_TIMEOUT_MS = 3_000; + +type CheckStatus = 'ok' | 'failing' | 'skipped'; +type Checks = Record; + +interface Report { + status: 'ok' | 'failing'; + checks: Checks; +} + +const isDev = process.env.NODE_ENV === 'development'; + +async function serves(app: FastifyInstance, host: string, url: string, contentType: string): Promise { + try { + const response = await app.inject({ method: 'GET', url, headers: { host }, remoteAddress: SELF_CHECK_REMOTE_ADDRESS }); + + return response.statusCode === 200 && String(response.headers['content-type']).includes(contentType) ? 'ok' : 'failing'; + } catch { + return 'failing'; + } +} + +async function databaseAnswers(app: FastifyInstance): Promise { + let timer: NodeJS.Timeout | undefined; + + try { + await Promise.race([ + // A real table, not `SELECT 1`: a database that accepts the connection but was never + // migrated is as unusable as one that refuses it. + prisma.user.findFirst({ select: { id: true } }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('database check timed out')), DATABASE_TIMEOUT_MS); + }), + ]); + + return 'ok'; + } catch (error) { + app.log.warn({ err: error }, 'health: the database check failed'); + + return 'failing'; + } finally { + clearTimeout(timer); + } +} + +async function servingChecks(app: FastifyInstance): Promise { + // In development Vite serves the two hosts, from source, and there is nothing built to check. + const skipped: CheckStatus = 'skipped'; + const [interfacePage, vaultFile, api] = await Promise.all([ + isDev ? skipped : serves(app, env.UI_HOST, '/', 'text/html'), + isDev ? skipped : serves(app, env.VAULT_HOST, '/vault.js', 'javascript'), + serves(app, env.UI_HOST, '/api/version', 'application/json'), + ]); + + return { interface: interfacePage, vault: vaultFile, api }; +} + +function report(checks: Checks): Report { + return { status: Object.values(checks).includes('failing') ? 'failing' : 'ok', checks }; +} + +// Probes come every few seconds, from every node, and the endpoints are public: one +// evaluation per window, whoever asks. +function cached(evaluate: () => Promise): () => Promise { + let last: { at: number; value: Promise } | undefined; + + return () => { + if (!last || Date.now() - last.at > CACHE_MS) { + last = { at: Date.now(), value: evaluate() }; + } + + return last.value; + }; +} + +export async function healthRoute(app: FastifyInstance): Promise { + // `hide` keeps infrastructure endpoints out of the generated OpenAPI document. + const hidden = { schema: { hide: true } }; + + const ready = cached(async () => report(await servingChecks(app))); + const startup = cached(async () => { + const [serving, database] = await Promise.all([servingChecks(app), databaseAnswers(app)]); + + return report({ ...serving, database }); + }); + + app.get('/health', hidden, async () => ({ status: 'ok' })); + + app.get('/health/ready', hidden, async (_, reply) => { + const result = await ready(); + + return reply.code(result.status === 'ok' ? 200 : 503).send(result); + }); + + app.get('/health/startup', hidden, async (_, reply) => { + const result = await startup(); + + return reply.code(result.status === 'ok' ? 200 : 503).send(result); + }); +} diff --git a/src/server/server.ts b/src/server/server.ts index df09e80..052f6d9 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -8,6 +8,7 @@ import { jwtAuthPlugin } from '@encryption/src/server/plugins/jwt-auth'; import { securityHeadersPlugin } from '@encryption/src/server/plugins/security-headers'; import { browserReportsRoute } from '@encryption/src/server/routes/browser-reports'; import { emergencyAccessRoute } from '@encryption/src/server/routes/emergency-access'; +import { SELF_CHECK_REMOTE_ADDRESS, healthRoute } from '@encryption/src/server/routes/health'; import { meRoute } from '@encryption/src/server/routes/me'; import { publicKeysRoute } from '@encryption/src/server/routes/public-keys'; import { securityTxtRoute } from '@encryption/src/server/routes/security-txt'; @@ -97,6 +98,9 @@ export async function createServer(options: CreateServerOptions = {}) { // API route never carries one (`/api/me`, `/api/vault/events`), a served module // always does. `/@…` covers Vite internals (`/@vite/client`) that have none. app.addHook('onResponse', async (request, reply) => { + // The readiness check asks the server for its own pages: not traffic. + if (request.socket.remoteAddress === SELF_CHECK_REMOTE_ADDRESS) return; + if (isDev) { const path = request.url.split('?')[0]; const lastSegment = path.slice(path.lastIndexOf('/') + 1); @@ -163,10 +167,8 @@ export async function createServer(options: CreateServerOptions = {}) { // they are not part of the API the interface consumes. const hidden = { schema: { hide: true } }; - // Health check - app.get('/health', hidden, async () => { - return { status: 'ok' }; - }); + // Liveness, readiness and startup checks + app.register(healthRoute); // Prevent indexing on both domains app.get('/robots.txt', hidden, async (_, reply) => { From fa0a8c6b66ddab3b0b00ed8182c4ff0098a33e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 23 Sep 2026 11:39:06 +0200 Subject: [PATCH 07/47] chore(deploy): enable preview pull requests to test a whole environment with other products --- .github/actions/setup-helm/action.yml | 11 +- .github/workflows/preview.yml | 54 ++ .gitignore | 4 + .prettierignore | 1 + CLAUDE.md | 2 +- .../applicationset-preview.example.yaml | 87 +++ .../__snapshots__/encryption.test.ts.snap | 1 - .../__snapshots__/suite-stack.test.ts.snap | 607 ++++++++++++++++ .../encryption/templates/migration-job.yaml | 2 +- deploy/helm/encryption/values.schema.json | 4 + deploy/helm/encryption/values.yaml | 4 + deploy/helm/suite-stack.test.ts | 549 +++++++++++++++ deploy/helm/suite-stack/.helmignore | 9 + deploy/helm/suite-stack/Chart.yaml | 32 + deploy/helm/suite-stack/README.md | 115 +++ .../helm/suite-stack/ci/staging-values.yaml | 31 + .../files/postgres/create-migrator-role.sql | 23 + .../files/postgres/create-runtime-role.sql | 21 + deploy/helm/suite-stack/templates/NOTES.txt | 32 + deploy/helm/suite-stack/templates/_checks.tpl | 147 ++++ .../helm/suite-stack/templates/_helpers.tpl | 58 ++ .../helm/suite-stack/templates/bootstrap.yaml | 265 +++++++ .../helm/suite-stack/templates/mailpit.yaml | 132 ++++ .../suite-stack/templates/onlyoffice.yaml | 152 ++++ deploy/helm/suite-stack/values.schema.json | 160 +++++ deploy/helm/suite-stack/values.yaml | 661 ++++++++++++++++++ deploy/helmfile/preview/helmfile.yaml.gotmpl | 75 ++ deploy/helmfile/preview/values.yaml.gotmpl | 195 ++++++ 28 files changed, 3429 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/preview.yml create mode 100644 deploy/argocd/applicationset-preview.example.yaml create mode 100644 deploy/helm/__snapshots__/suite-stack.test.ts.snap create mode 100644 deploy/helm/suite-stack.test.ts create mode 100644 deploy/helm/suite-stack/.helmignore create mode 100644 deploy/helm/suite-stack/Chart.yaml create mode 100644 deploy/helm/suite-stack/README.md create mode 100644 deploy/helm/suite-stack/ci/staging-values.yaml create mode 100644 deploy/helm/suite-stack/files/postgres/create-migrator-role.sql create mode 100644 deploy/helm/suite-stack/files/postgres/create-runtime-role.sql create mode 100644 deploy/helm/suite-stack/templates/NOTES.txt create mode 100644 deploy/helm/suite-stack/templates/_checks.tpl create mode 100644 deploy/helm/suite-stack/templates/_helpers.tpl create mode 100644 deploy/helm/suite-stack/templates/bootstrap.yaml create mode 100644 deploy/helm/suite-stack/templates/mailpit.yaml create mode 100644 deploy/helm/suite-stack/templates/onlyoffice.yaml create mode 100644 deploy/helm/suite-stack/values.schema.json create mode 100644 deploy/helm/suite-stack/values.yaml create mode 100644 deploy/helmfile/preview/helmfile.yaml.gotmpl create mode 100644 deploy/helmfile/preview/values.yaml.gotmpl diff --git a/.github/actions/setup-helm/action.yml b/.github/actions/setup-helm/action.yml index 6f5e561..8db52c0 100644 --- a/.github/actions/setup-helm/action.yml +++ b/.github/actions/setup-helm/action.yml @@ -1,16 +1,18 @@ name: Setup Helm -description: Helm and kubeconform for the chart tests and the chart release. +description: Helm and kubeconform for the chart tests and the chart release, helmfile for the test that renders deploy/helmfile/preview. runs: using: composite steps: - - name: Install Helm and kubeconform + - name: Install Helm, kubeconform and helmfile shell: bash env: HELM_VERSION: v3.19.0 HELM_SHA256: a7f81ce08007091b86d8bd696eb4d86b8d0f2e1b9f6c714be62f82f96a594496 KUBECONFORM_VERSION: v0.7.0 KUBECONFORM_SHA256: c31518ddd122663b3f3aa874cfe8178cb0988de944f29c74a0b9260920d115d3 + HELMFILE_VERSION: 1.1.7 + HELMFILE_SHA256: e9d870f4e502b9f0850d7e0546cab8b418ff7f44ff4df0ed54cd3df8dfda189c run: | # zizmor: ignore[github-env] the path written is a constant, never derived from an input set -euo pipefail mkdir -p "${HOME}/.local/bin" @@ -24,6 +26,11 @@ runs: echo "${KUBECONFORM_SHA256} /tmp/kubeconform.tgz" | sha256sum -c - tar -xzf /tmp/kubeconform.tgz -C "${HOME}/.local/bin" kubeconform + curl -sSfL "https://github.com/helmfile/helmfile/releases/download/v${HELMFILE_VERSION}/helmfile_${HELMFILE_VERSION}_linux_amd64.tar.gz" -o /tmp/helmfile.tgz + echo "${HELMFILE_SHA256} /tmp/helmfile.tgz" | sha256sum -c - + tar -xzf /tmp/helmfile.tgz -C "${HOME}/.local/bin" helmfile + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" "${HOME}/.local/bin/helm" version --short "${HOME}/.local/bin/kubeconform" -v + "${HOME}/.local/bin/helmfile" --version diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..ea34ad7 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,54 @@ +name: Preview environment + +# One ephemeral environment per pull request carrying the `preview` label. The environment +# itself is not created here: an Argo CD ApplicationSet in the deployment repository +# (deploy/argocd/applicationset-preview.example.yaml) lists the labelled pull requests +# and renders deploy/helmfile/preview at each one's own commit, then removes it when the +# pull request closes or loses the label. This repository only has to say where the +# environment is, and nudge Argo CD to look now rather than at its next poll. +# +# The product images the environment runs are set in the deployment repository, next to +# the ApplicationSet (`pr-` tags of a labelled upstream pull request, or a build +# pushed by hand, see deploy/helm/suite-stack/README.md, "Images"). The encryption image +# is the helmfile's default, the `main` tag: the CI publishes no image for a pull request. + +on: + pull_request: + types: [labeled, opened, synchronize] + +permissions: + contents: read + +jobs: + comment: + name: Say where it is + if: contains(github.event.pull_request.labels.*.name, 'preview') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: write + steps: + - uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + comment-tag: preview-url + message: | + :rocket: Preview of this pull request, full stack: + - Docs: https://${{ github.event.pull_request.number }}-docs.${{ vars.PREVIEW_DOMAIN }}/ + - Drive: https://${{ github.event.pull_request.number }}-drive.${{ vars.PREVIEW_DOMAIN }}/ + - Mailbox: https://${{ github.event.pull_request.number }}-mail.${{ vars.PREVIEW_DOMAIN }}/ + + Accounts: `alice.martin` / `alice.martin`, `bob.dupont` / `bob.dupont` (see `bootstrap.keycloak.users`), or create your own on the sign-in page (Register). + Everything in this environment is public: put nothing real in it. + The environment is destroyed when this pull request is closed or loses the `preview` label. + + notify-argocd: + name: Notify Argo CD + if: contains(github.event.pull_request.labels.*.name, 'preview') && vars.ARGOCD_PREPROD_WEBHOOK_URL != '' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main + with: + deployment_repo_path: ${{ secrets.DEPLOYMENT_REPO_URL }} + argocd_webhook_secret: ${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }} + argocd_url: ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }} diff --git a/.gitignore b/.gitignore index da5292f..5f74fae 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ npm-debug.log* .env.development.local .env.test.local .env.production.local + +# stack chart dependencies (allow moving version of encryption chart) +deploy/helm/suite-stack/charts/ +deploy/helm/suite-stack/Chart.lock diff --git a/.prettierignore b/.prettierignore index d602900..fbd8346 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ /.github/CODEOWNERS /build/ /deploy/helm/encryption/templates/ +/deploy/helm/suite-stack/templates/ /dist/ /node_modules/ /src/generated/ diff --git a/CLAUDE.md b/CLAUDE.md index 2a68dd4..df0e054 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ Single `package.json`, no workspaces. Source in `src/` with clear module separat - `src/demo/` — fake product pages for testing (two instances on different ports) - `src/i18n/` — French translations, i18next setup - `src/build/` — build-time helpers (browser versions from browserslist) -- `deploy/` — deployment material: PostgreSQL role scripts, the Helm chart and its tests (`deploy/helm/encryption.test.ts` renders it with the real `helm` binary and checks the Deployment against `src/server/env-schema.ts`), helmfile and Argo CD examples. The chart is versioned apart from the application: `chart/vX.Y.Z` tags publish `lasuite/encryption-chart` on Docker Hub and run only the chart jobs; `vX.Y.Z` tags release the image and skip the chart. A push to the CI branch republishes the image as `main` only when something outside `deploy/helm` changed, and the chart as `0.0.0-main` only when `deploy/helm/encryption` or `.github` changed, both compared with the commit of the last successful run on the branch (the `changes` job), so a failed release is published again by the next push. `image.tag` is therefore required in the values. +- `deploy/` — deployment material: PostgreSQL role scripts, the Helm chart and its tests (`deploy/helm/encryption.test.ts` renders it with the real `helm` binary and checks the Deployment against `src/server/env-schema.ts`), helmfile and Argo CD examples. The chart is versioned apart from the application: `chart/vX.Y.Z` tags publish `lasuite/encryption-chart` on Docker Hub and run only the chart jobs; `vX.Y.Z` tags release the image and skip the chart. A push to the CI branch republishes the image as `main` only when something outside `deploy/helm` changed, and the chart as `0.0.0-main` only when `deploy/helm/encryption` or `.github` changed, both compared with the commit of the last successful run on the branch (the `changes` job), so a failed release is published again by the next push. `image.tag` is therefore required in the values. `deploy/helm/suite-stack` is a second, unpublished chart for beta testing: the encryption chart (from the checkout) plus the published Docs, Drive and `dev-backend` (PostgreSQL, Redis, MinIO, Keycloak) charts as dependencies, a bootstrap Job that creates the databases, roles, buckets, OIDC clients and tester accounts and runs the encryption migration, Mailpit, and render-time checks that the blocks agree (`templates/_checks.tpl`); `deploy/helm/suite-stack.test.ts` covers it and needs the network for `helm dependency update`. `deploy/helmfile/preview` renders that stack as one environment per pull request (`feature` + `domain` inputs, hostnames derived, namespace `preview-`), deployed per pull request by an Argo CD ApplicationSet in the deployment repository (`deploy/argocd/applicationset-preview.example.yaml`, a pull request generator on the `preview` label); `.github/workflows/preview.yml` only comments the URLs and nudges Argo CD's webhook. - `.storybook/` — Storybook config with factory pattern from assistant-declaration ## Tech stack diff --git a/deploy/argocd/applicationset-preview.example.yaml b/deploy/argocd/applicationset-preview.example.yaml new file mode 100644 index 0000000..69363af --- /dev/null +++ b/deploy/argocd/applicationset-preview.example.yaml @@ -0,0 +1,87 @@ +# The GitOps way to run the preview environments, for the deployment repository (the one +# Argo CD watches for preprod and prod), not for this repository. One Application per +# open pull request of this repository that carries the `preview` label; the Application +# disappears, and prunes everything it created, when the pull request closes or loses +# the label. The chart and the helmfile are read at the pull request's own commit, so a +# pull request that changes the chart is previewed with its own chart, and nothing is +# ever published to a registry for it. +# +# Nothing is rendered in advance, by a workflow or anyone: Argo CD renders at sync time. +# For a Helm source it runs `helm template` itself in the checked-out commit; for a +# helmfile it needs a config management plugin, a sidecar of argocd-repo-server with the +# helmfile binary, whose `generate` command runs `helmfile template` in the checked-out +# directory and whose standard output IS the manifest Argo CD applies. The environment +# variables of the Application reach that command as ARGOCD_ENV_. The values below +# are those of the argo-cd-helmfile plugin (https://github.com/travisghansen/argo-cd-helmfile), +# the usual one; another plugin has other names. Its sidecar, for reference: +# +# - name: helmfile +# image: ghcr.io/travisghansen/argo-cd-helmfile:latest # pin a version +# command: [/var/run/argocd/argocd-cmp-server] +# securityContext: { runAsNonRoot: true, runAsUser: 999 } +# volumeMounts: +# - { mountPath: /var/run/argocd, name: var-files } +# - { mountPath: /home/argocd/cmp-server/plugins, name: plugins } +# - { mountPath: /tmp, name: helmfile-tmp } +# +# (its plugin.yaml, shipped in the image, declares `discover` on helmfile.yaml* and +# `generate` as `helmfile template`, so the Application only points at the directory). +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: encryption-preview + namespace: argocd +spec: + goTemplate: true + goTemplateOptions: ['missingkey=error'] + generators: + - pullRequest: + github: + owner: suitenumerique + repo: encryption + labels: + - preview + # A token with read access to pull requests; a webhook on the repository makes + # Argo CD react to label and push events instead of waiting for the next poll. + tokenRef: + secretName: github-token + key: token + requeueAfterSeconds: 300 + template: + metadata: + name: 'encryption-preview-{{ .number }}' + labels: + preview: 'true' + spec: + project: preview + source: + repoURL: https://github.com/suitenumerique/encryption.git + targetRevision: '{{ .head_sha }}' + path: deploy/helmfile/preview + plugin: + name: helmfile + # The helmfile reads the two inputs every hostname derives from as environment + # variables (FEATURE, DOMAIN, or their ARGOCD_ENV_ form), so any plugin that runs + # `helmfile -e preview template` in the directory renders the right environment. + # The products' image tags stay the helmfile's defaults. + env: + - name: FEATURE + value: '{{ .number }}' + - name: DOMAIN + value: ppr.example.org + # The two below are the argo-cd-helmfile plugin's own: the environment to + # render, and the release namespace following the destination. Another plugin + # selects the environment its own way (an APP_ENV variable, for instance). + - name: HELMFILE_GLOBAL_OPTIONS + value: '-e preview' + - name: HELMFILE_USE_CONTEXT_NAMESPACE + value: 'true' + destination: + server: https://kubernetes.default.svc + namespace: 'preview-{{ .number }}' + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/deploy/helm/__snapshots__/encryption.test.ts.snap b/deploy/helm/__snapshots__/encryption.test.ts.snap index ab2af44..49f5d8a 100644 --- a/deploy/helm/__snapshots__/encryption.test.ts.snap +++ b/deploy/helm/__snapshots__/encryption.test.ts.snap @@ -507,7 +507,6 @@ spec: env: - name: NODE_ENV value: production - - name: DATABASE_URL valueFrom: secretKeyRef: diff --git a/deploy/helm/__snapshots__/suite-stack.test.ts.snap b/deploy/helm/__snapshots__/suite-stack.test.ts.snap new file mode 100644 index 0000000..8cef5d1 --- /dev/null +++ b/deploy/helm/__snapshots__/suite-stack.test.ts.snap @@ -0,0 +1,607 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`the stack chart > renders the same objects of its own as last time (review the snapshot diff on any change) 1`] = ` +" +# Source: suite-stack/templates/onlyoffice.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: onlyoffice + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: onlyoffice +data: + # Loaded last by the server's configuration stack. Drive fetches the discovery document + # over the Service name, and the server builds the editor URLs of that document from + # the request it receives; \`wopi.host\` makes it use the public origin instead, the one + # the tester's browser opens. + local-production-linux.json: "{\\"wopi\\":{\\"host\\":\\"https://office.suite.example.org\\"}}" + +--- + +# Source: suite-stack/templates/mailpit.yaml +apiVersion: v1 +kind: Service +metadata: + # A fixed name: the products' SMTP host in the values. + name: mailpit + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: mailpit +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/component: mailpit + ports: + - name: smtp + port: 1025 + targetPort: smtp + - name: http + port: 8025 + targetPort: http + +--- + +# Source: suite-stack/templates/onlyoffice.yaml +apiVersion: v1 +kind: Service +metadata: + # A fixed name: Drive's discovery and conversion URLs in the values. + name: onlyoffice + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: onlyoffice +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/component: onlyoffice + ports: + - name: http + port: 80 + targetPort: http + +--- + +# Source: suite-stack/templates/mailpit.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mailpit + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: mailpit +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/component: mailpit + template: + metadata: + labels: + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/component: mailpit + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: mailpit + image: "axllent/mailpit:v1.31.1" + ports: + - name: smtp + containerPort: 1025 + - name: http + containerPort: 8025 + env: + # Messages live in memory: a restart empties the mailbox, which is fine here. + - name: MP_SMTP_AUTH_ACCEPT_ANY + value: 'true' + - name: MP_SMTP_AUTH_ALLOW_INSECURE + value: 'true' + readinessProbe: + httpGet: + path: /livez + port: http + periodSeconds: 5 + livenessProbe: + httpGet: + path: /livez + port: http + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + memory: 256Mi + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi + +--- + +# Source: suite-stack/templates/onlyoffice.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: onlyoffice + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: onlyoffice +spec: + replicas: 1 + # One instance, nothing persisted: two would not share the editing sessions. + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/component: onlyoffice + template: + metadata: + labels: + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/component: onlyoffice + spec: + automountServiceAccountToken: false + # The image is a whole machine under supervisord (nginx, node services, its own + # PostgreSQL, RabbitMQ and Redis) that starts as root and drops privileges per + # service itself, so none of the hardening of the other pods applies to it. + containers: + - name: onlyoffice + image: "onlyoffice/documentserver:9.4.0.1" + ports: + - name: http + containerPort: 80 + env: + - name: WOPI_ENABLED + value: 'true' + - name: JWT_ENABLED + value: 'true' + - name: JWT_SECRET + value: "ThisIsAnExampleKeyForDevPurposeOnly" + # The server refuses to fetch documents from private addresses by default; + # inside the cluster that is where Drive is. + - name: ALLOW_PRIVATE_IP_ADDRESS + value: 'true' + # The first boot generates fonts and initializes its database: minutes, not + # seconds. The startup probe covers ten of them. + startupProbe: + httpGet: + path: /healthcheck + port: http + periodSeconds: 10 + failureThreshold: 60 + readinessProbe: + httpGet: + path: /healthcheck + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthcheck + port: http + periodSeconds: 30 + timeoutSeconds: 5 + volumeMounts: + - name: config + mountPath: /etc/onlyoffice/documentserver/local-production-linux.json + subPath: local-production-linux.json + readOnly: true + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + memory: 3Gi + volumes: + - name: config + configMap: + name: onlyoffice + +--- + +# Source: suite-stack/templates/mailpit.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mailpit + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: mailpit + annotations: + cert-manager.io/cluster-issuer: letsencrypt +spec: + ingressClassName: "nginx" + tls: + - hosts: + - "mail.suite.example.org" + secretName: "mailpit-tls" + rules: + - host: "mail.suite.example.org" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: mailpit + port: + name: http + +--- + +# Source: suite-stack/templates/onlyoffice.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: onlyoffice + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: onlyoffice + annotations: + cert-manager.io/cluster-issuer: letsencrypt + nginx.ingress.kubernetes.io/proxy-body-size: 100m + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +spec: + ingressClassName: "nginx" + tls: + - hosts: + - "office.suite.example.org" + secretName: "onlyoffice-tls" + rules: + - host: "office.suite.example.org" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: onlyoffice + port: + name: http + +--- + +# Source: suite-stack/templates/bootstrap.yaml +apiVersion: v1 +kind: Secret +metadata: + name: "stack-suite-stack-bootstrap-1" + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: bootstrap + annotations: + helm.sh/hook: post-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +type: Opaque +stringData: + create-migrator-role.sql: | + -- The role that applies schema migrations (\`prisma migrate deploy\`). It owns the + -- \`encryption\` schema, so it is the only one allowed to create or alter tables, and it + -- is used once per release, never by the running server. + -- + -- Everything lives in a dedicated schema rather than \`public\`: every role in a + -- database has USAGE on \`public\` (and CREATE, before PostgreSQL 15), so on a shared + -- server another application's role could list the tables. Nobody but the two roles + -- below gets as much as USAGE on \`encryption\`. + -- + -- Run once, connected to the service's database as its administrator: + -- psql "$ADMIN_DATABASE_URL" -v password="''" -f deploy/postgres/create-migrator-role.sql + \\set ON_ERROR_STOP on + + CREATE ROLE encryption_migrator LOGIN PASSWORD :password; + + CREATE SCHEMA encryption AUTHORIZATION encryption_migrator; + + -- The first migration opens with \`CREATE SCHEMA IF NOT EXISTS "encryption"\`, and + -- PostgreSQL checks the CREATE privilege on the database BEFORE looking at whether the + -- schema already exists, so without this the migration fails with "permission denied + -- for database" even though the schema is there. \`:DBNAME\` is the database psql is + -- connected to. + GRANT CREATE ON DATABASE :"DBNAME" TO encryption_migrator; + + create-runtime-role.sql: | + -- The role the server connects with (\`DATABASE_URL\`). Data only: it can read and write + -- rows but cannot create, alter or drop anything, so a compromised server cannot + -- rewrite the schema, and a migration cannot run by accident from a pod. + -- + -- Run once, after create-migrator-role.sql, connected as the same administrator: + -- psql "$ADMIN_DATABASE_URL" -v password="''" -f deploy/postgres/create-runtime-role.sql + \\set ON_ERROR_STOP on + + CREATE ROLE encryption_runtime LOGIN PASSWORD :password; + + GRANT USAGE ON SCHEMA encryption TO encryption_runtime; + + -- Tables that already exist (none on a fresh database, all of them on an existing one). + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA encryption TO encryption_runtime; + + -- Tables that future migrations create: without this line every release that adds a + -- table would need a manual GRANT before the server can use it. It applies to objects + -- created by the migrator role, so it must be run by a superuser or by a member of + -- that role (the administrator who created it, in PostgreSQL 16 and later). + ALTER DEFAULT PRIVILEGES FOR ROLE encryption_migrator IN SCHEMA encryption + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO encryption_runtime; + + postgres.sh: | + set -eu + until pg_isready -q; do echo "waiting for PostgreSQL at \${PGHOST}"; sleep 2; done + exists() { [ "$(psql -tAc "$1")" = 1 ]; } + if ! exists "SELECT 1 FROM pg_database WHERE datname = 'docs'"; then + psql -v ON_ERROR_STOP=1 -c 'CREATE DATABASE "docs"' + echo "created database docs" + fi + if ! exists "SELECT 1 FROM pg_database WHERE datname = 'drive'"; then + psql -v ON_ERROR_STOP=1 -c 'CREATE DATABASE "drive"' + echo "created database drive" + fi + if ! exists "SELECT 1 FROM pg_database WHERE datname = 'encryption'"; then + psql -v ON_ERROR_STOP=1 -c 'CREATE DATABASE "encryption"' + echo "created database encryption" + fi + # The encryption service's two roles, in its database, from deploy/postgres/. + if ! exists "SELECT 1 FROM pg_roles WHERE rolname = 'encryption_migrator'"; then + psql -d "encryption" -v ON_ERROR_STOP=1 -v password="'\${ENCRYPTION_MIGRATOR_PASSWORD}'" -f /scripts/create-migrator-role.sql + echo "created role encryption_migrator" + fi + if ! exists "SELECT 1 FROM pg_roles WHERE rolname = 'encryption_runtime'"; then + psql -d "encryption" -v ON_ERROR_STOP=1 -v password="'\${ENCRYPTION_RUNTIME_PASSWORD}'" -f /scripts/create-runtime-role.sql + echo "created role encryption_runtime" + fi + minio.sh: | + set -eu + until mc alias set local "http://\${MINIO_HOST}:\${MINIO_PORT}" "\${MINIO_USER}" "\${MINIO_PASSWORD}" > /dev/null 2>&1; do + echo "waiting for MinIO at \${MINIO_HOST}:\${MINIO_PORT}"; sleep 2 + done + mc mb --ignore-existing "local/docs-media-storage" + mc version enable "local/docs-media-storage" + mc mb --ignore-existing "local/drive-media-storage" + mc version enable "local/drive-media-storage" + # The terms text as a file: kcadm posts JSON, and the text carries apostrophes the shell + # quoting of the script below could not (the header travels as a variable instead). + notice.json: "{\\"termsText\\":\\"Cet environnement sert à essayer LaSuite. Tout le monde peut y créer un compte, la console d'administration et la boîte mail sont publiques : un compte peut être lu ou repris par n'importe qui. N'y mettez aucune donnée réelle, ni document, ni mot de passe utilisé ailleurs. Tout peut être effacé à tout moment.\\",\\"termsTitle\\":\\"Environnement de test\\"}" + keycloak.sh: | + set -eu + KCADM=/opt/keycloak/bin/kcadm.sh + CONFIG=/tmp/kcadm.config + until "$KCADM" config credentials --config "$CONFIG" --server "http://\${KEYCLOAK_HOST}:8080" --realm master --user "\${KEYCLOAK_ADMIN}" --password "\${KEYCLOAK_ADMIN_PASSWORD}" > /dev/null 2>&1; do + echo "waiting for Keycloak at \${KEYCLOAK_HOST}:8080"; sleep 3 + done + kc() { "$KCADM" "$@" --config "$CONFIG"; } + # The notice: the realm's display name heads every sign-in page, the terms text is a + # message override of the login theme, and the terms step is a default required action, + # so every account registered from now on accepts it before its first sign-in. The + # action's alias is looked up: Keycloak renamed it (lowercase until 20, uppercase after). + kc update "realms/\${REALM}" -s "displayName=\${NOTICE_HEADER}" -s "displayNameHtml=\${NOTICE_HEADER_HTML}" -s internationalizationEnabled=true -s 'supportedLocales=["fr"]' -s defaultLocale=fr + kc create "realms/\${REALM}/localization/fr" -f /scripts/notice.json + TERMS=$(kc get authentication/required-actions -r "$REALM" --fields alias --format csv --noquotes | grep -i '^terms_and_conditions$') + kc update "authentication/required-actions/\${TERMS}" -r "$REALM" -s enabled=true -s defaultAction=true + # Creates the client, or updates it in place so the URIs follow the values. + ensure_client() { + local client_id=$1 definition=$2 id + id=$(kc get clients -r "$REALM" -q clientId="$client_id" --fields id --format csv --noquotes) + if [ -z "$id" ]; then + echo "$definition" | kc create clients -r "$REALM" -f - + echo "created client \${client_id}" + else + echo "$definition" | kc update "clients/\${id}" -r "$REALM" -f - + echo "updated client \${client_id}" + fi + } + # Creates the user if missing, with the terms to accept at first sign-in; the password + # is set every run, so the values win. + ensure_user() { + local username=$1 email=$2 first_name=$3 last_name=$4 password=$5 id + id=$(kc get users -r "$REALM" -q username="$username" -q exact=true --fields id --format csv --noquotes) + if [ -z "$id" ]; then + kc create users -r "$REALM" -s username="$username" -s email="$email" -s firstName="$first_name" -s lastName="$last_name" -s enabled=true -s emailVerified=true -s "requiredActions=[\\"\${TERMS}\\"]" + echo "created user \${username}" + fi + kc set-password -r "$REALM" --username "$username" --new-password "$password" + } + ensure_client 'docs' '{"attributes":{"post.logout.redirect.uris":"+"},"clientId":"docs","directAccessGrantsEnabled":false,"enabled":true,"protocol":"openid-connect","publicClient":false,"redirectUris":["https://docs.suite.example.org/*"],"secret":"ThisIsAnExampleKeyForDevPurposeOnly","standardFlowEnabled":true,"webOrigins":["https://docs.suite.example.org"]}' + ensure_client 'drive' '{"attributes":{"post.logout.redirect.uris":"+"},"clientId":"drive","directAccessGrantsEnabled":false,"enabled":true,"protocol":"openid-connect","publicClient":false,"redirectUris":["https://drive.suite.example.org/*"],"secret":"ThisIsAnExampleKeyForDevPurposeOnly","standardFlowEnabled":true,"webOrigins":["https://drive.suite.example.org"]}' + ensure_client 'encryption' '{"attributes":{"post.logout.redirect.uris":"+"},"clientId":"encryption","directAccessGrantsEnabled":false,"enabled":true,"protocol":"openid-connect","publicClient":true,"redirectUris":["https://encryption.suite.example.org/auth/callback"],"standardFlowEnabled":true,"webOrigins":["https://encryption.suite.example.org"]}' + ensure_user 'alice.martin' 'alice.martin@example.org' 'Alice' 'Martin' 'alice.martin' + ensure_user 'bob.dupont' 'bob.dupont@example.org' 'Bob' 'Dupont' 'bob.dupont' + ENCRYPTION_MIGRATION_DATABASE_URL: "postgresql://encryption_migrator:migrator@postgres:5432/encryption?schema=encryption" + +--- + +# Source: suite-stack/templates/bootstrap.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: "stack-suite-stack-bootstrap-1" + namespace: "suite" + labels: + helm.sh/chart: suite-stack-0.0.0-dev + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/component: bootstrap + annotations: + helm.sh/hook: post-install,pre-upgrade + helm.sh/hook-weight: "0" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded +spec: + backoffLimit: 2 + activeDeadlineSeconds: 900 + template: + metadata: + labels: + app.kubernetes.io/name: suite-stack + app.kubernetes.io/instance: stack + app.kubernetes.io/component: bootstrap + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + # Sequential by construction: init containers run one after the other. + initContainers: + - name: postgres + image: "postgres:16-alpine" + command: [/bin/sh, /scripts/postgres.sh] + env: + - name: PGHOST + value: "postgres" + - name: PGUSER + value: "dinum" + - name: PGPASSWORD + value: "pass" + - name: PGDATABASE + value: "postgres" + - name: ENCRYPTION_MIGRATOR_PASSWORD + value: "migrator" + - name: ENCRYPTION_RUNTIME_PASSWORD + value: "runtime" + securityContext: &containerSecurityContext + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: &volumeMounts + - name: scripts + mountPath: /scripts + readOnly: true + - name: tmp + mountPath: /tmp + - name: minio + image: "quay.io/minio/mc" + command: [/bin/sh, /scripts/minio.sh] + env: + - name: MC_CONFIG_DIR + value: /tmp/mc + - name: MINIO_HOST + value: "minio" + - name: MINIO_PORT + value: "9000" + - name: MINIO_USER + value: "dinum" + - name: MINIO_PASSWORD + value: "password" + securityContext: *containerSecurityContext + volumeMounts: *volumeMounts + - name: keycloak + image: "quay.io/keycloak/keycloak:20.0.1" + command: [/bin/bash, /scripts/keycloak.sh] + env: + - name: HOME + value: /tmp + - name: KEYCLOAK_HOST + value: "shared-keycloak" + - name: KEYCLOAK_ADMIN + value: "admin" + - name: KEYCLOAK_ADMIN_PASSWORD + value: "pass" + - name: REALM + value: "suite" + - name: NOTICE_HEADER + value: "Environnement de test : rien n'y est privé" + - name: NOTICE_HEADER_HTML + value: "Environnement de test : rien n'y est privé" + securityContext: *containerSecurityContext + volumeMounts: *volumeMounts + containers: + # The service's migration, as its own chart runs it (templates/migration-job.yaml + # there), with the migrator role the first init container created. + - name: migrate + image: "lasuite/encryption:main" + imagePullPolicy: Always + args: + - node_modules/prisma/build/index.js + - migrate + - deploy + env: + - name: NODE_ENV + value: production + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: "stack-suite-stack-bootstrap-1" + key: ENCRYPTION_MIGRATION_DATABASE_URL + securityContext: + <<: *containerSecurityContext + runAsUser: 65532 + runAsGroup: 65532 + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: scripts + secret: + secretName: "stack-suite-stack-bootstrap-1" + defaultMode: 0444 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi +" +`; diff --git a/deploy/helm/encryption/templates/migration-job.yaml b/deploy/helm/encryption/templates/migration-job.yaml index 83aa92b..4d63be5 100644 --- a/deploy/helm/encryption/templates/migration-job.yaml +++ b/deploy/helm/encryption/templates/migration-job.yaml @@ -110,7 +110,7 @@ spec: env: - name: NODE_ENV value: production - {{- include "encryption.secretEnv" (dict "root" $ "env" "DATABASE_URL" "value" $m.url "existing" $m.existingSecret "key" "DATABASE_URL" "secretName" $hookName) | nindent 12 }} + {{- include "encryption.secretEnv" (dict "root" $ "env" "DATABASE_URL" "value" $m.url "existing" $m.existingSecret "key" "DATABASE_URL" "secretName" $hookName) | trim | nindent 12 }} {{- with $m.resources }} resources: {{- toYaml . | nindent 12 }} diff --git a/deploy/helm/encryption/values.schema.json b/deploy/helm/encryption/values.schema.json index 7386876..fc009e9 100644 --- a/deploy/helm/encryption/values.schema.json +++ b/deploy/helm/encryption/values.schema.json @@ -107,6 +107,10 @@ } }, "properties": { + "enabled": { + "type": "boolean", + "description": "Read by a parent chart's dependency condition when this chart is a subchart (deploy/helm/suite-stack); the chart itself ignores it." + }, "global": { "type": "object" }, diff --git a/deploy/helm/encryption/values.yaml b/deploy/helm/encryption/values.yaml index 8fac104..16631a4 100644 --- a/deploy/helm/encryption/values.yaml +++ b/deploy/helm/encryption/values.yaml @@ -6,6 +6,10 @@ # the OIDC provider and an error collector are external services the operator already # runs; the chart takes their coordinates and never manages persistent storage. +# Read by a parent chart's dependency condition when this chart is one of its subcharts +# (deploy/helm/suite-stack). The chart itself ignores it. +enabled: true + image: repository: lasuite/encryption # The application version to run. Required (or a digest): the chart is versioned on diff --git a/deploy/helm/suite-stack.test.ts b/deploy/helm/suite-stack.test.ts new file mode 100644 index 0000000..b53a10f --- /dev/null +++ b/deploy/helm/suite-stack.test.ts @@ -0,0 +1,549 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseAllDocuments, parse as parseYaml } from 'yaml'; + +/** + * The stack chart is four subcharts and a bootstrap Job, so what can go wrong is not one + * chart's shape but the seams between them: a product told to reach a database the + * bootstrap never creates, a client the realm does not have, a vault origin the product + * is not allowed to embed. templates/_checks.tpl turns each of those into a render + * error; this file proves every check fires, and that the objects the chart owns are + * what the snapshot says. + * + * Needs `helm` on the PATH and the network (the product charts come from GitHub Pages). + */ + +const CHART = resolve(import.meta.dirname, 'suite-stack'); +const STAGING_VALUES = resolve(CHART, 'ci/staging-values.yaml'); +const ROLE_SCRIPTS = resolve(import.meta.dirname, '../postgres'); +const PREVIEW_HELMFILE = resolve(import.meta.dirname, '../helmfile/preview/helmfile.yaml.gotmpl'); + +interface EnvEntry { + name: string; + value?: string; + valueFrom?: { secretKeyRef?: { name: string; key: string } }; +} + +interface Container { + name: string; + image: string; + env?: EnvEntry[]; + command?: string[]; +} + +interface Manifest { + kind: string; + metadata: { name: string; annotations?: Record; labels?: Record }; + stringData?: Record; + data?: Record; + spec?: { template?: { spec: { initContainers?: Container[]; containers: Container[] } } }; +} + +function run(command: string, args: string[], input?: string, env?: NodeJS.ProcessEnv): string { + try { + return execFileSync(command, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...env } }); + } catch (error) { + const failure = error as { stderr?: string; stdout?: string; message: string }; + + throw new Error(`${command} ${args.join(' ')} failed:\n${failure.stderr || failure.stdout || failure.message}`, { cause: error }); + } +} + +function isInstalled(command: string, versionArgs: string[]): boolean { + try { + execFileSync(command, versionArgs, { stdio: 'ignore' }); + + return true; + } catch { + return false; + } +} + +// The subcharts, fetched every run with `dependency update`, not `build`: with a +// Chart.lock, `build` wants every repository registered with `helm repo add`, `update` +// resolves the URLs itself. The local encryption chart is packaged again each time, since +// an existing archive is kept whatever the checkout now contains. +function buildDependencies(): void { + const charts = resolve(CHART, 'charts'); + + if (existsSync(charts)) { + for (const archive of readdirSync(charts).filter((name) => name.startsWith('encryption-chart-'))) { + rmSync(resolve(charts, archive)); + } + } + + run('helm', ['dependency', 'update', CHART]); +} + +interface Render { + text: string; + docs: Manifest[]; + /** The objects this chart renders itself, apart from the subcharts'. */ + own: Manifest[]; + /** Same, plus the encryption service's: the two charts this repository answers for. */ + ours: string; +} + +function render(values: string[] = [], set: string[] = [], setJson: string[] = []): Render { + const text = run('helm', [ + 'template', + 'stack', + CHART, + '--namespace', + 'suite', + ...values.flatMap((file) => ['--values', file]), + ...set.flatMap((entry) => ['--set', entry]), + ...setJson.flatMap((entry) => ['--set-json', entry]), + ]); + const chunks = text.split(/^---$/m).filter((chunk) => chunk.trim() !== ''); + const isOurs = (chunk: string) => /^# Source: suite-stack\/(?:templates|charts\/encryption)\//m.test(chunk); + const parse = (chunk: string) => + parseAllDocuments(chunk) + .map((document) => document.toJS() as Manifest | null) + // A product chart renders an empty CronJobList, an object without metadata. + .filter((document): document is Manifest => document !== null && typeof document === 'object' && 'metadata' in document); + + return { + text, + docs: chunks.flatMap(parse), + own: chunks.filter((chunk) => /^# Source: suite-stack\/templates\//m.test(chunk)).flatMap(parse), + ours: chunks.filter(isOurs).join('\n---\n'), + }; +} + +function renderError(set: string[], setJson: string[] = []): string { + try { + render([], set, setJson); + } catch (error) { + return (error as Error).message; + } + + throw new Error('expected the render to fail'); +} + +function find(docs: Manifest[], kind: string, name: string): Manifest { + const found = docs.find((document) => document.kind === kind && document.metadata.name === name); + + if (!found) throw new Error(`no ${kind} named ${name} in the render`); + + return found; +} + +function containerEnv(container: Container | undefined): Map { + return new Map((container?.env ?? []).map((entry) => [entry.name, entry])); +} + +function mainContainer(manifest: Manifest, name?: string): Container { + const containers = manifest.spec?.template?.spec.containers ?? []; + const container = name ? containers.find((candidate) => candidate.name === name) : containers[0]; + + if (!container) throw new Error(`no container ${name ?? ''} in ${manifest.kind} ${manifest.metadata.name}`); + + return container; +} + +const defaults = parseYaml(readFileSync(resolve(CHART, 'values.yaml'), 'utf8')) as { + bootstrap: { keycloak: { clients: { clientId: string; public?: boolean; secret?: string; redirectUris: string[]; webOrigins: string[] }[] } }; +}; + +if (!isInstalled('helm', ['version', '--short'])) { + throw new Error('The chart tests need the helm binary on the PATH (brew install helm, or https://helm.sh/docs/intro/install/)'); +} + +buildDependencies(); + +describe('the stack chart', () => { + const full = render(); + const staging = render([STAGING_VALUES]); + // A developer's own override, validated on demand: SUITE_STACK_VALUES=my-values.yaml npm run test:helm + const custom = process.env.SUITE_STACK_VALUES ? render([process.env.SUITE_STACK_VALUES]) : undefined; + + it('lints strictly and validates our objects against the Kubernetes API schemas', () => { + expect(run('helm', ['lint', '--strict', CHART])).toContain('0 chart(s) failed'); + + if (!isInstalled('kubeconform', ['-v'])) { + if (process.env.CI) throw new Error('kubeconform is required in CI'); + console.warn('kubeconform is not installed, skipping the API schema validation (brew install kubeconform)'); + + return; + } + + const cache = mkdtempSync(resolve(tmpdir(), 'kubeconform-')); + + // Only what this repository renders: the product charts are validated upstream, and + // one object of the shared chart (its MinIO StatefulSet, missing `serviceName`) does + // not pass the 1.30 schemas. + for (const { ours } of [full, staging, ...(custom ? [custom] : [])]) { + const summary = run('kubeconform', ['-strict', '-summary', '-kubernetes-version', '1.30.0', '-cache', cache], ours); + + expect(summary).toMatch(/Invalid: 0/); + expect(summary).toMatch(/Errors: 0/); + } + }); + + it('renders the same objects of its own as last time (review the snapshot diff on any change)', () => { + expect(full.own.map((document) => `${document.kind}/${document.metadata.name}`).sort()).toEqual( + [ + 'Secret/stack-suite-stack-bootstrap-1', + 'Job/stack-suite-stack-bootstrap-1', + 'Deployment/mailpit', + 'Service/mailpit', + 'Ingress/mailpit', + 'ConfigMap/onlyoffice', + 'Deployment/onlyoffice', + 'Service/onlyoffice', + 'Ingress/onlyoffice', + ].sort() + ); + expect( + full.text + .split(/^---$/m) + .filter((chunk) => /^# Source: suite-stack\/templates\//m.test(chunk)) + .join('\n---\n') + ).toMatchSnapshot(); + }); + + it('ships the role scripts of deploy/postgres, unchanged', () => { + for (const script of ['create-migrator-role.sql', 'create-runtime-role.sql']) { + expect(readFileSync(resolve(CHART, 'files/postgres', script), 'utf8')).toBe(readFileSync(resolve(ROLE_SCRIPTS, script), 'utf8')); + } + }); + + it('gives every product the same shared services, by fixed names', () => { + for (const name of ['postgres', 'redis', 'minio', 'shared-keycloak', 'mailpit', 'onlyoffice']) { + find(full.docs, 'Service', name); + } + + const keycloak = containerEnv( + find(full.own, 'Job', 'stack-suite-stack-bootstrap-1').spec?.template?.spec.initContainers?.find((c) => c.name === 'keycloak') + ); + + expect(keycloak.get('KEYCLOAK_HOST')?.value).toBe('shared-keycloak'); + + const docs = containerEnv(mainContainer(find(full.docs, 'Deployment', 'docs-backend'))); + const drive = containerEnv(mainContainer(find(full.docs, 'Deployment', 'drive-backend'))); + const encryption = containerEnv(mainContainer(find(full.docs, 'Deployment', 'stack-encryption'))); + + for (const env of [docs, drive]) { + expect(env.get('DB_HOST')?.value).toBe('postgres'); + expect(env.get('REDIS_URL')?.value).toMatch(/^redis:\/\/[^@]+@redis:6379\//); + expect(env.get('DJANGO_EMAIL_HOST')?.value).toBe('mailpit'); + expect(env.get('OIDC_OP_JWKS_ENDPOINT')?.value).toBe('https://auth.suite.example.org/realms/suite/protocol/openid-connect/certs'); + } + + expect(docs.get('AWS_S3_ENDPOINT_URL')?.value).toBe('http://minio:9000'); + expect(drive.get('WOPI_ONLYOFFICE_DISCOVERY_URL')?.value).toBe('http://onlyoffice/hosting/discovery'); + expect(drive.get('WOPI_SRC_BASE_URL')?.value).toBe('https://drive.suite.example.org'); + expect(containerEnv(mainContainer(find(full.own, 'Deployment', 'onlyoffice'))).get('JWT_SECRET')?.value).toBe( + drive.get('WOPI_ONLYOFFICE_CONVERT_JWT_SECRET')?.value + ); + expect(find(full.own, 'ConfigMap', 'onlyoffice').data?.['local-production-linux.json']).toBe( + '{"wopi":{"host":"https://office.suite.example.org"}}' + ); + // Drive's own Job, told to wait for the server, like the migration waits for the database. + expect(mainContainer(find(full.docs, 'Job', 'drive-backend-configure-wopi')).command?.join(' ')).toContain('until python -c'); + expect(docs.get('REDIS_URL')?.value).not.toBe(drive.get('REDIS_URL')?.value); + expect(encryption.get('MAILER_SMTP_HOST')?.value).toBe('mailpit'); + expect(encryption.get('OIDC_ISSUER')?.value).toBe('https://auth.suite.example.org/realms/suite'); + + const secret = find(full.docs, 'Secret', encryption.get('DATABASE_URL')?.valueFrom?.secretKeyRef?.name ?? ''); + + expect(secret.stringData?.DATABASE_URL).toBe('postgresql://encryption_runtime:runtime@postgres:5432/encryption?schema=encryption'); + }); + + it('bootstraps after the shared services exist and before an upgrade rolls out', () => { + const job = find(full.own, 'Job', 'stack-suite-stack-bootstrap-1'); + const secret = find(full.own, 'Secret', 'stack-suite-stack-bootstrap-1'); + + expect(job.metadata.annotations?.['helm.sh/hook']).toBe('post-install,pre-upgrade'); + expect(secret.metadata.annotations?.['helm.sh/hook']).toBe('post-install,pre-upgrade'); + expect(Number(secret.metadata.annotations?.['helm.sh/hook-weight'])).toBeLessThan(Number(job.metadata.annotations?.['helm.sh/hook-weight'])); + + // Sequential: init containers, in the order the products need them. + expect(job.spec?.template?.spec.initContainers?.map((container) => container.name)).toEqual(['postgres', 'minio', 'keycloak']); + + const scripts = secret.stringData ?? {}; + + for (const database of ['docs', 'drive', 'encryption']) { + expect(scripts['postgres.sh']).toContain(`CREATE DATABASE "${database}"`); + } + + expect(scripts['postgres.sh']).toContain('create-migrator-role.sql'); + expect(scripts['minio.sh']).toContain('mc mb --ignore-existing "local/drive-media-storage"'); + + for (const client of ['docs', 'drive', 'encryption']) { + expect(scripts['keycloak.sh']).toContain(`ensure_client '${client}' `); + } + + expect(scripts['keycloak.sh']).toContain(`ensure_user 'alice.martin' `); + + // The notice every tester reads: on the sign-in pages, and as terms accepted once. + const keycloak = containerEnv(job.spec?.template?.spec.initContainers?.find((container) => container.name === 'keycloak')); + + expect(keycloak.get('NOTICE_HEADER')?.value).toBe("Environnement de test : rien n'y est privé"); + expect(keycloak.get('NOTICE_HEADER_HTML')?.value).toBe('Environnement de test : rien n'y est privé'); + expect(scripts['keycloak.sh']).toContain('-s "displayNameHtml=${NOTICE_HEADER_HTML}" -s internationalizationEnabled=true'); + expect(JSON.parse(scripts['notice.json']).termsText).toContain("N'y mettez aucune donnée réelle"); + expect(scripts['keycloak.sh']).toContain(`localization/fr" -f /scripts/notice.json`); + expect(scripts['keycloak.sh']).toContain(`grep -i '^terms_and_conditions$'`); + expect(scripts['keycloak.sh']).toContain('required-actions/${TERMS}" -r "$REALM" -s enabled=true -s defaultAction=true'); + expect(scripts['keycloak.sh']).toContain('-s "requiredActions=[\\"${TERMS}\\"]"'); + }); + + // Argo CD ignores the Helm hook annotations of an object that carries its own, and would + // otherwise run the bootstrap in PreSync (from `pre-upgrade`), before the shared + // services exist. It is a Sync hook instead. No sync waves anywhere: a wave waits for + // every object before it to be healthy, and the shared chart's bucket Job is deleted by + // Kubernetes the moment it completes, so Argo CD reports it Missing and a wave after it + // never starts. Every consumer waits for what it needs by itself. + it('runs its bootstrap as an Argo CD Sync hook, with no sync wave to get stuck on', () => { + const annotation = (document: Manifest, name: string) => document.metadata.annotations?.[`argocd.argoproj.io/${name}`]; + + for (const kind of ['Secret', 'Job']) { + expect(annotation(find(full.own, kind, 'stack-suite-stack-bootstrap-1'), 'hook'), kind).toBe('Sync'); + } + + expect(annotation(find(full.own, 'Job', 'stack-suite-stack-bootstrap-1'), 'hook-delete-policy')).toContain('HookSucceeded'); + + for (const name of ['docs-backend-migrate', 'drive-backend-migrate', 'docs-backend-createsuperuser', 'drive-backend-createsuperuser']) { + expect(annotation(find(full.docs, 'Job', name), 'hook'), name).toBe('Sync'); + } + + for (const document of full.docs) { + expect(annotation(document, 'sync-wave'), `${document.kind}/${document.metadata.name}`).toBeUndefined(); + } + }); + + it('migrates the encryption service itself, with the migrator role and the deployed image', () => { + const job = find(full.own, 'Job', 'stack-suite-stack-bootstrap-1'); + const migrate = mainContainer(job, 'migrate'); + const server = mainContainer(find(full.docs, 'Deployment', 'stack-encryption')); + + expect(migrate.image).toBe(server.image); + expect(containerEnv(migrate).get('DATABASE_URL')?.valueFrom?.secretKeyRef).toEqual({ + name: 'stack-suite-stack-bootstrap-1', + key: 'ENCRYPTION_MIGRATION_DATABASE_URL', + }); + expect(find(full.own, 'Secret', 'stack-suite-stack-bootstrap-1').stringData?.ENCRYPTION_MIGRATION_DATABASE_URL).toBe( + 'postgresql://encryption_migrator:migrator@postgres:5432/encryption?schema=encryption' + ); + // Not twice: the service's own hook is off, so nothing of its migration is rendered. + expect(full.docs.filter((document) => document.metadata.labels?.['app.kubernetes.io/component'] === 'migration')).toEqual([]); + }); + + it('takes the images of the branches under test from the override file', () => { + expect(mainContainer(find(staging.docs, 'Deployment', 'docs-backend')).image).toBe('myaccount/impress-backend:e2ee'); + expect(mainContainer(find(staging.docs, 'Deployment', 'docs-frontend')).image).toBe('myaccount/impress-frontend:e2ee'); + expect(mainContainer(find(staging.docs, 'Deployment', 'docs-y-provider')).image).toBe('myaccount/impress-y-provider:e2ee'); + expect(mainContainer(find(staging.docs, 'Deployment', 'drive-backend')).image).toBe('myaccount/drive-backend:e2ee'); + expect(mainContainer(find(staging.docs, 'Deployment', 'drive-frontend')).image).toBe('myaccount/drive-frontend:e2ee'); + expect(mainContainer(find(staging.docs, 'Deployment', 'stack-encryption')).image).toBe('myaccount/encryption:e2ee'); + expect(mainContainer(find(staging.own, 'Job', 'stack-suite-stack-bootstrap-1'), 'migrate').image).toBe('myaccount/encryption:e2ee'); + }); + + it('refuses a stack whose blocks disagree, naming both values', () => { + const cases: [string, RegExp][] = [ + ['docs.backend.envVars.DB_NAME=other', /DB_NAME "other" must be listed in bootstrap\.postgres\.databases/], + ['drive.backend.envVars.DB_HOST=db', /drive\.backend\.envVars\.DB_HOST must be the shared PostgreSQL, "postgres"/], + ['docs.backend.envVars.AWS_STORAGE_BUCKET_NAME=other', /AWS_STORAGE_BUCKET_NAME "other" must be shared\.minio\.bucket or listed/], + ['docs.backend.envVars.OIDC_RP_CLIENT_ID=other', /OIDC_RP_CLIENT_ID "other" has no entry in bootstrap\.keycloak\.clients/], + ['drive.backend.envVars.OIDC_RP_CLIENT_SECRET=other', /OIDC_RP_CLIENT_SECRET differs from bootstrap\.keycloak\.clients\[drive\]\.secret/], + ['docs.backend.envVars.OIDC_OP_JWKS_ENDPOINT=https://other/certs', /OIDC_OP_JWKS_ENDPOINT must be under the shared Keycloak's realm/], + ['drive.backend.envVars.REDIS_URL=redis://user:pass@redis:6379/1', /docs and drive share the Redis URL/], + ['docs.backend.envVars.ENCRYPTION_FEATURE_ENABLED=False', /ENCRYPTION_FEATURE_ENABLED must be "True"/], + ['drive.backend.envVars.ENCRYPTION_VAULT_URL=https://other', /ENCRYPTION_VAULT_URL must be "https:\/\/data\.encryption\.suite\.example\.org"/], + ['docs.backend.envVars.ENCRYPTION_INTERFACE_URL=https://other', /ENCRYPTION_INTERFACE_URL must be "https:\/\/encryption\.suite\.example\.org"/], + [ + 'encryption.config.allowedFrameAncestors={https://docs.suite.example.org}', + /allowedFrameAncestors must contain "https:\/\/drive\.suite\.example\.org"/, + ], + ['drive.backend.envVars.WOPI_CLIENTS=collabora', /WOPI_CLIENTS must be "onlyoffice"/], + [ + 'drive.backend.envVars.WOPI_ONLYOFFICE_DISCOVERY_URL=http://other/hosting/discovery', + /WOPI_ONLYOFFICE_DISCOVERY_URL must be "http:\/\/onlyoffice\/hosting\/discovery"/, + ], + ['drive.backend.envVars.WOPI_SRC_BASE_URL=https://other', /WOPI_SRC_BASE_URL must be "https:\/\/drive\.suite\.example\.org"/], + ['drive.backend.envVars.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET=other', /WOPI_ONLYOFFICE_CONVERT_JWT_SECRET differs from onlyoffice\.jwtSecret/], + ['encryption.database.url=postgresql://other', /encryption\.database\.url must be the runtime role's URL/], + ['encryption.config.oidc.issuer=https://other/realms/suite', /issuer must be the shared Keycloak's realm/], + ['encryption.config.oidc.jwksUrl=https://other/certs', /jwksUrl must be/], + ['encryption.config.oidc.clientId=other', /clientId "other" has no entry in bootstrap\.keycloak\.clients/], + ['encryption.config.mailer.smtpHost=smtp.example.org', /smtpHost must be "mailpit"/], + ['bootstrap.encryption.database=other', /bootstrap\.encryption\.database "other" must be listed/], + ['shared.postgres.serviceNameOverride=', /shared\.postgres\.serviceNameOverride is required/], + ['shared.fullnameOverride=', /shared\.fullnameOverride is required/], + ['shared.redis.enabled=false', /shared\.redis\.enabled must be true/], + ]; + + for (const [set, message] of cases) { + expect(renderError([set]), set).toMatch(message); + } + }); + + it('refuses a client of the wrong kind, or one missing the redirect URI a product needs', () => { + // `--set` on a list index drops the other entries, so the whole list goes as JSON. + const clients = (edit: (client: (typeof defaults.bootstrap.keycloak.clients)[number]) => void) => + `bootstrap.keycloak.clients=${JSON.stringify( + defaults.bootstrap.keycloak.clients.map((client) => { + const copy = structuredClone(client); + + edit(copy); + + return copy; + }) + )}`; + + expect( + renderError( + [], + [ + clients((client) => { + if (client.clientId === 'encryption') { + client.public = false; + client.secret = 'x'; + } + }), + ] + ) + ).toMatch(/clients\[encryption\] must be public/); + expect( + renderError( + [], + [ + clients((client) => { + if (client.clientId === 'docs') client.public = true; + }), + ] + ) + ).toMatch(/clients\[docs\] must be confidential/); + expect( + renderError( + [], + [ + clients((client) => { + if (client.clientId === 'drive') client.redirectUris = ['https://other/*']; + }), + ] + ) + ).toMatch(/clients\[drive\]\.redirectUris must contain "https:\/\/drive\.suite\.example\.org\/\*"/); + expect( + renderError( + [], + [ + clients((client) => { + if (client.clientId === 'encryption') client.redirectUris = ['https://other/auth/callback']; + }), + ] + ) + ).toMatch(/clients\[encryption\]\.redirectUris must contain "https:\/\/encryption\.suite\.example\.org\/auth\/callback"/); + }); + + // The per-pull-request environment: every hostname derived from `feature` and `domain` + // by deploy/helmfile/preview, on top of the chart's values, through helmfile itself. + it('derives a whole preview environment from a pull request number and a domain', () => { + if (!isInstalled('helmfile', ['--version'])) { + if (process.env.CI) throw new Error('helmfile is required in CI'); + console.warn('helmfile is not installed, skipping the preview environment render (brew install helmfile)'); + + return; + } + + const text = run('helmfile', [ + '-e', + 'preview', + '-f', + PREVIEW_HELMFILE, + '--state-values-set', + 'feature=42,domain=ppr.example.net', + '--state-values-set', + 'images.docs.tag=pr-42,images.docsFrontend.tag=pr-42,images.docsYProvider.tag=pr-42,images.drive.tag=pr-42,images.driveFrontend.tag=pr-42,images.encryption.tag=pr-42', + 'template', + ]); + + // Nothing of the placeholder domain or namespace survives: a hostname the helmfile + // forgot to derive would silently point a product at the wrong environment. + expect(text).not.toMatch(/suite\.example\.org|suite\.svc/); + + for (const host of [ + '42-docs.ppr.example.net', + '42-drive.ppr.example.net', + '42-encryption.ppr.example.net', + 'data.42-encryption.ppr.example.net', + '42-auth.ppr.example.net', + '42-s3.ppr.example.net', + '42-s3-console.ppr.example.net', + '42-mail.ppr.example.net', + '42-office.ppr.example.net', + ]) { + expect(text, host).toContain(host); + } + + expect(text).toContain('namespace: "preview-42"'); + expect(text).toContain('minio.preview-42.svc.cluster.local'); + expect(text).toContain('image: "lasuite/impress-backend:pr-42"'); + expect(text).toContain('image: "lasuite/drive-backend:pr-42"'); + expect(text).toContain('image: "lasuite/encryption:pr-42"'); + expect(text).not.toMatch(/image: "lasuite\/(?:impress|drive)-[a-z-]+:main"/); + }); + + // Argo CD hands the inputs to its plugin as environment variables of the Application, + // which a sidecar plugin sees prefixed. Without this path every preview renders the + // defaults, `ci-docs.example.org`, and the ingress answers nothing. + it('reads the preview inputs from the environment, a state value winning over it', () => { + if (!isInstalled('helmfile', ['--version'])) return; + + const args = ['-e', 'preview', '-f', PREVIEW_HELMFILE, 'template']; + const fromEnvironment = run('helmfile', args, undefined, { FEATURE: '7', DOMAIN: 'ppr.example.net' }); + + expect(fromEnvironment).toContain('7-docs.ppr.example.net'); + expect(fromEnvironment).toContain('namespace: "preview-7"'); + expect(fromEnvironment).not.toMatch(/ci-[a-z]+\.example\.org/); + + const prefixed = run('helmfile', args, undefined, { ARGOCD_ENV_FEATURE: '8', ARGOCD_ENV_DOMAIN: 'ppr.example.net' }); + + expect(prefixed).toContain('8-encryption.ppr.example.net'); + + const overridden = run('helmfile', [...args.slice(0, -1), '--state-values-set', 'feature=9', 'template'], undefined, { + FEATURE: '7', + DOMAIN: 'ppr.example.net', + }); + + expect(overridden).toContain('9-docs.ppr.example.net'); + expect(overridden).not.toContain('7-docs.'); + }); + + it('rejects a password or a name the scripts could not quote', () => { + expect(renderError(["bootstrap.keycloak.users[0].password=a'b"])).toMatch(/does not match pattern/); + expect(renderError(['bootstrap.encryption.runtimePassword=a b'])).toMatch(/does not match pattern/); + expect(renderError(['bootstrap.postgres.databases[0]=Docs'])).toMatch(/does not match pattern/); + }); + + it('renders nothing for a product that is off, and no migration without the service', () => { + const without = render( + [], + ['encryption.enabled=false', 'docs.backend.envVars.ENCRYPTION_FEATURE_ENABLED=False', 'drive.backend.envVars.ENCRYPTION_FEATURE_ENABLED=False'] + ); + + expect(without.docs.some((document) => document.metadata.name.startsWith('stack-encryption'))).toBe(false); + expect(mainContainer(find(without.own, 'Job', 'stack-suite-stack-bootstrap-1')).name).toBe('done'); + expect(find(without.own, 'Secret', 'stack-suite-stack-bootstrap-1').stringData?.['postgres.sh']).not.toContain('create-migrator-role'); + + const shared = render( + [], + [ + 'docs.enabled=false', + 'drive.enabled=false', + 'mailpit.enabled=false', + 'onlyoffice.enabled=false', + 'encryption.config.mailer.smtpHost=smtp.example.org', + ] + ); + const names = shared.docs.map((document) => document.metadata.name); + + expect(names.some((name) => name.startsWith('docs-') || name.startsWith('drive-') || name === 'mailpit' || name === 'onlyoffice')).toBe(false); + find(shared.docs, 'Deployment', 'stack-encryption'); + }); +}); diff --git a/deploy/helm/suite-stack/.helmignore b/deploy/helm/suite-stack/.helmignore new file mode 100644 index 0000000..8f62754 --- /dev/null +++ b/deploy/helm/suite-stack/.helmignore @@ -0,0 +1,9 @@ +.DS_Store +.git/ +.gitignore +*.swp +*.bak +*.tmp +*.orig +*~ +ci/ diff --git a/deploy/helm/suite-stack/Chart.yaml b/deploy/helm/suite-stack/Chart.yaml new file mode 100644 index 0000000..2491963 --- /dev/null +++ b/deploy/helm/suite-stack/Chart.yaml @@ -0,0 +1,32 @@ +apiVersion: v2 +type: application +name: suite-stack +description: >- + A complete environment for testing the encryption feature inside the products: the + encryption service, Docs and Drive from any image, and the services they share (one + PostgreSQL, one Redis, one MinIO, one Keycloak, one mail catcher). For beta testers + and demos, not for production. +home: https://github.com/suitenumerique/encryption +sources: + - https://github.com/suitenumerique/encryption +version: 0.0.0-dev +kubeVersion: '>=1.27.0-0' +dependencies: + - name: encryption-chart + alias: encryption + version: '>=0.0.0-0' + repository: file://../encryption + condition: encryption.enabled + - name: docs + version: 4.5.0 + repository: https://suitenumerique.github.io/docs + condition: docs.enabled + - name: drive + version: 0.15.0 + repository: https://suitenumerique.github.io/drive + condition: drive.enabled + - name: dev-backend + alias: shared + version: 0.0.12 + repository: https://suitenumerique.github.io/helm-dev-backend + condition: shared.enabled diff --git a/deploy/helm/suite-stack/README.md b/deploy/helm/suite-stack/README.md new file mode 100644 index 0000000..b3f82da --- /dev/null +++ b/deploy/helm/suite-stack/README.md @@ -0,0 +1,115 @@ +# suite-stack + +One Helm release that deploys everything a beta tester needs to try the encryption feature inside the products: the encryption service, Docs and Drive built from the branches under test, and the services all three share (one PostgreSQL, one Redis, one MinIO, one Keycloak, one mail catcher). + +It exists because the products' own pipelines only ship images of `main`, and the feature cannot be merged there behind a flag. So the developer builds the product images from the integration branches, pushes them to a registry of their own, and this chart deploys them next to the service, on infrastructure it creates itself. Nothing in it is a production pattern: passwords are plaintext values, the shared services are the products' own development chart, and a `helm uninstall` throws the lot away. + +## What is in the release + +| Piece | Chart | Notes | +| ------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `encryption` | `../encryption`, packaged from this checkout | Its migration hook is off; the bootstrap Job migrates instead (see below). | +| `docs` | [suitenumerique/docs](https://github.com/suitenumerique/docs), `src/helm` | Pinned in `Chart.yaml`; pick the version that matches the branch's era. | +| `drive` | [suitenumerique/drive](https://github.com/suitenumerique/drive), `src/helm` | Same. | +| `shared` | [suitenumerique/helm-dev-backend](https://github.com/suitenumerique/helm-dev-backend) | PostgreSQL, Redis, MinIO, Keycloak (realm imported at first boot). | +| bootstrap Job | `templates/bootstrap.yaml` | Databases, roles, buckets, OIDC clients, tester accounts, encryption migration. | +| Mailpit | `templates/mailpit.yaml` | SMTP for every product, a web mailbox for the testers. | +| OnlyOffice | `templates/onlyoffice.yaml` | The document server Drive edits office files with (WOPI), collaborative. | + +Every product reaches the shared services by fixed names (`postgres`, `redis`, `minio`, `mailpit`, `onlyoffice`, and the public Keycloak hostname), whatever the release is called, so the values of one product can be copied for the next. + +## The bootstrap Job + +The shared chart creates one database, one bucket and one realm with one user. Everything else the products need is created by a Job that runs once the services are up (a `post-install` hook) and, on an upgrade, before the products roll out (`pre-upgrade`). Every step is idempotent, and the Job runs on every upgrade: adding a tester or a client is editing the values and running `helm upgrade`. + +1. `postgres`: creates every database of `bootstrap.postgres.databases`, then the encryption service's two roles with the scripts of `deploy/postgres/` (copied into `files/`, a test keeps them identical). +2. `minio`: creates every bucket of `bootstrap.minio.buckets`. +3. `keycloak`: creates or updates the OIDC clients of `bootstrap.keycloak.clients` and the accounts of `bootstrap.keycloak.users` (email marked verified, password reset each run). It also writes `bootstrap.keycloak.notice` on the realm: the header of every sign-in page, and the terms every new account accepts before its first sign-in. Registration is open to anyone with the URL, and the admin console and the mailbox are public, so the notice is what tells a tester that nothing here is private. +4. `migrate`: `prisma migrate deploy` with the encryption image of the release and the migrator role, exactly what the service's own migration hook runs. That hook is a `pre-install` one, which Helm runs before the shared PostgreSQL exists, so the stack turns it off (`encryption.database.migration.enabled: false`) and runs the migration here, after the database and the role exist. + +The Docs and Drive migration Jobs are not hooks: they start with the release and wait for their database in a loop, so they settle on their own once step 1 is done. + +## Under Argo CD + +The bootstrap Job is a Helm hook (`post-install`, `pre-upgrade`), which Argo CD would translate into PreSync and run before the shared services exist: the Job's first container would wait for a PostgreSQL that is never created. Argo CD ignores the Helm annotations of an object that carries its own, so the Job and its Secret are Argo CD `Sync` hooks instead, applied together with everything else. No sync wave orders them: each step of the Job waits for the service it needs, the Docs and Drive migration Jobs (also `Sync` hooks) wait for their database in a loop, and the encryption pods restart on their startup probe until the migration has run. That is the same shape as the Docs deployment. + +Sync waves were tried and do not work here: a wave waits for every object before it to be healthy, and the shared chart ships a bucket-creation Job with `ttlSecondsAfterFinished: 0`, which Kubernetes deletes the moment it completes. Argo CD then reports it Missing, and a wave placed after it never starts. The same Job shows as OutOfSync on every sync, which is harmless: it is idempotent and the bootstrap creates the buckets anyway. + +The hooks run again at every sync, which is what makes editing the testers' list or rotating a password a plain commit. + +## Coherence checks + +The four subcharts each read their own block, so a hostname or a client id is written in several places. `templates/_checks.tpl` makes the render fail, naming both values, when they disagree: a product's `DB_NAME` absent from the databases the bootstrap creates, an `OIDC_RP_CLIENT_ID` without a client, a client secret that differs, a redirect URI that does not cover the product's origin, `ENCRYPTION_VAULT_URL` that is not the vault's host, a product origin missing from `allowedFrameAncestors`, two products on the same Redis database index, and the encryption database URL not being the runtime role the bootstrap creates. `helm template` on your values is therefore the first test to run. + +## Images + +Whichever way the product images are produced, the stack only ever sees a reference: `ci/staging-values.yaml` (or the `images` inputs of the preview helmfile) is where it is set, and the coherence checks do not look at it. Two ways exist: + +1. **The products' own preview publishing.** A pull request in the upstream Docs repository carrying the `preview` label gets its images pushed by the Docs CI as `lasuite/impress-backend:pr-`, `impress-frontend:pr-` and `impress-y-provider:pr-`, republished on every push to that pull request. This is the path with no manual step: open the integration branches as upstream pull requests, label them, and point the stack at the `pr-` tags. Drive does not push images for pull requests today; once it does, the same applies to it. +2. **A build by hand**, pushed to a registry account of your own (the targets are those of each repository's `docker-hub.yml`): + + ```bash + # Docs: backend, frontend, y-provider + docker build --target backend-production -t myaccount/impress-backend:e2ee . + docker build --target frontend-production -t myaccount/impress-frontend:e2ee -f src/frontend/Dockerfile . + docker build --target y-provider -t myaccount/impress-y-provider:e2ee -f src/frontend/servers/y-provider/Dockerfile . + # Drive: backend, frontend + docker build --target backend-production -t myaccount/drive-backend:e2ee . + docker build --target frontend-production -t myaccount/drive-frontend:e2ee -f src/frontend/Dockerfile . + # The encryption service (this repository), or use the `main` tag the CI publishes + docker build -t myaccount/encryption:e2ee . + docker push ... + ``` + + Build for the architecture of the cluster's nodes (`--platform linux/amd64` from an ARM laptop). + +## Deploying + +Prerequisites on the cluster: an ingress controller answering to the `nginx` class, cert-manager with a ClusterIssuer named `letsencrypt` (both names are inputs), and one wildcard DNS record on the domain. The encryption service needs TLS, the browser refuses the vault otherwise; and its vault keys are shared between products only when all origins share one registrable domain (see the encryption chart's README, "Storage partitioning constraint"). + +**Through the helmfile (the normal way).** `deploy/helmfile/preview/` derives every hostname from two inputs, `feature` (a short label) and `domain`, plus the image references, and passes the result to the chart. Nothing is written twice: the chart's `values.yaml` keeps its defaults and the helmfile overrides only what carries a hostname, the namespace or an image. `feature` is any label: the testers' environment is `feature=beta`, a pull request preview is its number. + +```bash +helmfile -e preview -f deploy/helmfile/preview/helmfile.yaml.gotmpl \ + --state-values-set feature=beta,domain=suite.example.org \ + --state-values-set images.docs.tag=pr-2694,images.docsFrontend.tag=pr-2694,images.docsYProvider.tag=pr-2694 \ + --state-values-set images.drive.tag=e2ee,images.driveFrontend.tag=e2ee \ + template > /dev/null # then `apply` instead of `template` +kubectl -n preview-beta get jobs,pods -w +``` + +Hosts come out as `beta-docs.suite.example.org`, `beta-drive`, `beta-encryption` and `data.beta-encryption`, `beta-auth`, `beta-s3`, `beta-s3-console`, `beta-mail`, in the namespace `preview-beta`. Everything else (testers, passwords, replicas) is the chart's defaults; to change those too, add a values file to the release in the helmfile. + +**With plain Helm.** The chart alone cannot derive one hostname from another: each subchart reads its own block, and Helm gives a parent no way to compute a subchart's values. So without the helmfile the domain is written out in full: copy `values.yaml`, replace `suite.example.org` by your domain and `suite` (in the two `minio.suite.svc.cluster.local` names) by your namespace, put the image references in a second file shaped like `ci/staging-values.yaml`, then: + +```bash +cd deploy/helm/suite-stack +helm dependency update . # not `build`: with a Chart.lock, `build` wants the repositories registered +helm template stack . --namespace suite -f my-values.yaml -f my-images.yaml > /dev/null +helm upgrade --install stack . --namespace suite --create-namespace -f my-values.yaml -f my-images.yaml +``` + +A missed replacement is caught by the coherence checks for every value they cover, and `SUITE_STACK_VALUES=my-values.yaml npm run test:helm` renders your file through every assertion of `deploy/helm/suite-stack.test.ts` as well. + +The notes printed at the end of either path list every URL and account. Keycloak's admin console (`shared.keycloak.username` / `password`) lets you add accounts by hand too; the encryption service accepts unverified emails in this stack (`acceptUnverifiedEmail: true`) so an account created there works without a second step. + +## One environment per pull request + +The same helmfile with `feature` set to the pull request number is the preview environment: the chart is read from the checkout, so a pull request that changes it is previewed with its own chart, and nothing is published for it. The machinery lives in the deployment repository, not here: `deploy/argocd/applicationset-preview.example.yaml` is an ApplicationSet whose pull request generator lists the pull requests of this repository carrying the `preview` label and renders the helmfile at each one's commit through a helmfile plugin; when the pull request closes or loses the label, the Application goes and prunes its namespace. The inputs travel as environment variables of the Application, `FEATURE` (the number) and `DOMAIN`, because that is what a plugin can pass to the command it runs: the helmfile reads them (also under the `ARGOCD_ENV_` prefix a sidecar plugin adds) when no state value is given. An Ingress carrying `ci-docs.example.org`, the defaults, means the variables did not reach the render. This repository's part is `.github/workflows/preview.yml`: a comment with the URLs, and a webhook nudge so Argo CD re-evaluates now rather than at its next poll. + +## Adding another product + +The stack is the unit to copy for the next integration, not the encryption chart. To add a product whose chart is published: + +1. A `dependencies` entry in `Chart.yaml`, with a `condition: .enabled`. +2. A `:` block in `values.yaml`: that chart's own values, with its database, Redis, S3 and OIDC coordinates pointed at the fixed names above (the Docs and Drive blocks are the template, they differ only in names and ports). +3. Its database in `bootstrap.postgres.databases`, its bucket in `bootstrap.minio.buckets`, its client in `bootstrap.keycloak.clients`, its origin in `encryption.config.allowedFrameAncestors`. +4. Its lines in `templates/_checks.tpl` and the test file, so a mismatch fails the render. + +## Known limits + +- The shared chart's MinIO StatefulSet has no `serviceName`, which the 1.30 API schemas reject; the chart test validates only the objects this repository renders. Clusters from 1.32 accept it. +- The realm is imported once, at first boot. Changing `shared.keycloak.realm` afterwards does nothing; the bootstrap's clients and users are the way to change the realm. +- On an upgrade that changes the encryption image, the migration runs before the new pods (the hook is `pre-upgrade`); a change of the shared chart's passwords in the same upgrade is not seen by that run. +- Passwords, secrets and names go through shell quoting in the bootstrap scripts, so the values schema refuses quotes in them. +- The document server keeps nothing: a restart of its pod ends the editing sessions that were open (the files themselves are in MinIO). Drive learns its capabilities once, through the `configure-wopi` Job, and again every night at 3:00; a change of `onlyoffice.host` needs that Job to run again (a sync does it under Argo CD, `helm upgrade` does it too). diff --git a/deploy/helm/suite-stack/ci/staging-values.yaml b/deploy/helm/suite-stack/ci/staging-values.yaml new file mode 100644 index 0000000..bba29be --- /dev/null +++ b/deploy/helm/suite-stack/ci/staging-values.yaml @@ -0,0 +1,31 @@ +# What a developer overrides on top of values.yaml: the images of the product branches +# under test, either the `pr-` tags the products' CI pushes for a labelled +# upstream pull request, or a build pushed to a registry of their own (see the README, +# "Images"). Every other value can stay, provided the domain and namespace are replaced +# in values.yaml. +encryption: + image: + repository: myaccount/encryption + tag: e2ee + +docs: + image: + repository: myaccount/impress-backend + tag: e2ee + frontend: + image: + repository: myaccount/impress-frontend + tag: e2ee + yProvider: + image: + repository: myaccount/impress-y-provider + tag: e2ee + +drive: + image: + repository: myaccount/drive-backend + tag: e2ee + frontend: + image: + repository: myaccount/drive-frontend + tag: e2ee diff --git a/deploy/helm/suite-stack/files/postgres/create-migrator-role.sql b/deploy/helm/suite-stack/files/postgres/create-migrator-role.sql new file mode 100644 index 0000000..eea5af4 --- /dev/null +++ b/deploy/helm/suite-stack/files/postgres/create-migrator-role.sql @@ -0,0 +1,23 @@ +-- The role that applies schema migrations (`prisma migrate deploy`). It owns the +-- `encryption` schema, so it is the only one allowed to create or alter tables, and it +-- is used once per release, never by the running server. +-- +-- Everything lives in a dedicated schema rather than `public`: every role in a +-- database has USAGE on `public` (and CREATE, before PostgreSQL 15), so on a shared +-- server another application's role could list the tables. Nobody but the two roles +-- below gets as much as USAGE on `encryption`. +-- +-- Run once, connected to the service's database as its administrator: +-- psql "$ADMIN_DATABASE_URL" -v password="''" -f deploy/postgres/create-migrator-role.sql +\set ON_ERROR_STOP on + +CREATE ROLE encryption_migrator LOGIN PASSWORD :password; + +CREATE SCHEMA encryption AUTHORIZATION encryption_migrator; + +-- The first migration opens with `CREATE SCHEMA IF NOT EXISTS "encryption"`, and +-- PostgreSQL checks the CREATE privilege on the database BEFORE looking at whether the +-- schema already exists, so without this the migration fails with "permission denied +-- for database" even though the schema is there. `:DBNAME` is the database psql is +-- connected to. +GRANT CREATE ON DATABASE :"DBNAME" TO encryption_migrator; diff --git a/deploy/helm/suite-stack/files/postgres/create-runtime-role.sql b/deploy/helm/suite-stack/files/postgres/create-runtime-role.sql new file mode 100644 index 0000000..8c33f5c --- /dev/null +++ b/deploy/helm/suite-stack/files/postgres/create-runtime-role.sql @@ -0,0 +1,21 @@ +-- The role the server connects with (`DATABASE_URL`). Data only: it can read and write +-- rows but cannot create, alter or drop anything, so a compromised server cannot +-- rewrite the schema, and a migration cannot run by accident from a pod. +-- +-- Run once, after create-migrator-role.sql, connected as the same administrator: +-- psql "$ADMIN_DATABASE_URL" -v password="''" -f deploy/postgres/create-runtime-role.sql +\set ON_ERROR_STOP on + +CREATE ROLE encryption_runtime LOGIN PASSWORD :password; + +GRANT USAGE ON SCHEMA encryption TO encryption_runtime; + +-- Tables that already exist (none on a fresh database, all of them on an existing one). +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA encryption TO encryption_runtime; + +-- Tables that future migrations create: without this line every release that adds a +-- table would need a manual GRANT before the server can use it. It applies to objects +-- created by the migrator role, so it must be run by a superuser or by a member of +-- that role (the administrator who created it, in PostgreSQL 16 and later). +ALTER DEFAULT PRIVILEGES FOR ROLE encryption_migrator IN SCHEMA encryption + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO encryption_runtime; diff --git a/deploy/helm/suite-stack/templates/NOTES.txt b/deploy/helm/suite-stack/templates/NOTES.txt new file mode 100644 index 0000000..6494d3b --- /dev/null +++ b/deploy/helm/suite-stack/templates/NOTES.txt @@ -0,0 +1,32 @@ +{{ .Chart.Name }} {{ .Chart.Version }} as release {{ .Release.Name }} in {{ .Release.Namespace }}. + +{{- if .Values.docs.enabled }} +Docs https://{{ .Values.docs.ingress.host }} +{{- end }} +{{- if .Values.drive.enabled }} +Drive https://{{ .Values.drive.ingress.host }} +{{- end }} +{{- if .Values.encryption.enabled }} +Encryption https://{{ .Values.encryption.hosts.interface }} (interface), https://{{ .Values.encryption.hosts.vault }} (vault) +{{- end }} +Keycloak https://{{ .Values.shared.keycloak.hostname }} (admin console: {{ .Values.shared.keycloak.username }}), realm {{ .Values.shared.keycloak.realm.name }} +{{- if .Values.shared.minio.consoleIngress.enabled }} +MinIO https://{{ .Values.shared.minio.consoleIngress.hostname }} (console: {{ .Values.shared.minio.username }}) +{{- end }} +{{- if and .Values.mailpit.enabled .Values.mailpit.ingress.enabled }} +Mailbox https://{{ .Values.mailpit.host }} (every email sent by the stack) +{{- end }} +{{- if and .Values.onlyoffice.enabled .Values.onlyoffice.ingress.enabled }} +Office https://{{ .Values.onlyoffice.host }} (the editor Drive opens office files with) +{{- end }} + +Tester accounts (bootstrap.keycloak.users): +{{- range .Values.bootstrap.keycloak.users }} + {{ .username }} / {{ .password }} ({{ .email }}) +{{- end }} + +The bootstrap Job creates the databases, buckets, clients and accounts after the shared +services are up, then migrates the encryption service; the products' own migration Jobs +wait for their database. Watch it settle with: + + kubectl -n {{ .Release.Namespace }} get jobs,pods diff --git a/deploy/helm/suite-stack/templates/_checks.tpl b/deploy/helm/suite-stack/templates/_checks.tpl new file mode 100644 index 0000000..ce7e51e --- /dev/null +++ b/deploy/helm/suite-stack/templates/_checks.tpl @@ -0,0 +1,147 @@ +{{/* +The four subcharts each read their own block of values, so the same hostname, database +name or client id is written several times. A mismatch does not fail the render, it +produces a stack where a product cannot log in, cannot reach its database, or embeds an +iframe the encryption service refuses. These checks turn every such mismatch into a +render error naming the two values that disagree. Included once, from the bootstrap Job. +*/}} +{{- define "suite-stack.checks" -}} +{{- $v := .Values }} +{{- $clients := $v.bootstrap.keycloak.clients }} +{{- $issuer := include "suite-stack.issuer" . }} + +{{- range $service := list "postgres" "redis" "minio" "keycloak" }} +{{- if not (index $v.shared $service).enabled }} +{{- fail (printf "shared.%s.enabled must be true: every product block below reaches it" $service) }} +{{- end }} +{{- end }} + +{{- /* The encryption service against the shared services and the bootstrap. */}} +{{- if $v.encryption.enabled }} +{{- $e := $v.encryption }} +{{- $b := $v.bootstrap.encryption }} +{{- if not (has $b.database $v.bootstrap.postgres.databases) }} +{{- fail (printf "bootstrap.encryption.database %q must be listed in bootstrap.postgres.databases" $b.database) }} +{{- end }} +{{- $runtimeUrl := include "suite-stack.encryptionDatabaseUrl" (dict "role" "runtime" "password" $b.runtimePassword "host" (include "suite-stack.postgresHost" $) "database" $b.database) }} +{{- if ne $e.database.url $runtimeUrl }} +{{- fail (printf "encryption.database.url must be the runtime role's URL the bootstrap creates: %q (got %q)" $runtimeUrl $e.database.url) }} +{{- end }} +{{- if $e.database.migration.enabled }} +{{- fail "encryption.database.migration.enabled must be false: the service's migration hook would run before the shared PostgreSQL exists, so the bootstrap Job runs the migration instead" }} +{{- end }} +{{- if ne $e.config.oidc.issuer $issuer }} +{{- fail (printf "encryption.config.oidc.issuer must be the shared Keycloak's realm: %q (got %q)" $issuer $e.config.oidc.issuer) }} +{{- end }} +{{- $jwks := printf "%s/protocol/openid-connect/certs" $issuer }} +{{- if ne $e.config.oidc.jwksUrl $jwks }} +{{- fail (printf "encryption.config.oidc.jwksUrl must be %q (got %q)" $jwks $e.config.oidc.jwksUrl) }} +{{- end }} +{{- $callback := printf "https://%s/auth/callback" $e.hosts.interface }} +{{- $found := false }} +{{- range $clients }} +{{- if eq .clientId $e.config.oidc.clientId }} +{{- $found = true }} +{{- if not .public }} +{{- fail (printf "bootstrap.keycloak.clients[%s] must be public: the encryption interface signs in from the browser" .clientId) }} +{{- end }} +{{- if not (has $callback .redirectUris) }} +{{- fail (printf "bootstrap.keycloak.clients[%s].redirectUris must contain %q, the interface's callback" .clientId $callback) }} +{{- end }} +{{- end }} +{{- end }} +{{- if not $found }} +{{- fail (printf "encryption.config.oidc.clientId %q has no entry in bootstrap.keycloak.clients" $e.config.oidc.clientId) }} +{{- end }} +{{- if and $v.mailpit.enabled (ne $e.config.mailer.smtpHost "mailpit") }} +{{- fail (printf "encryption.config.mailer.smtpHost must be \"mailpit\", the mail catcher's Service (got %q)" $e.config.mailer.smtpHost) }} +{{- end }} +{{- end }} + +{{- /* One Redis: two products on the same database index would share Celery queues. */}} +{{- $redisUrls := dict }} +{{- range $product := list "docs" "drive" }} +{{- $p := index $v $product }} +{{- if $p.enabled }} +{{- $url := toString $p.backend.envVars.REDIS_URL }} +{{- if hasKey $redisUrls $url }} +{{- fail (printf "%s and %s share the Redis URL %q: give each product its own database index" (index $redisUrls $url) $product $url) }} +{{- end }} +{{- $_ := set $redisUrls $url $product }} +{{- end }} +{{- end }} + +{{- /* Each Django product against the shared services, the bootstrap and the encryption service. */}} +{{- range $product := list "docs" "drive" }} +{{- $p := index $v $product }} +{{- if $p.enabled }} +{{- $env := $p.backend.envVars }} +{{- $origin := printf "https://%s" $p.ingress.host }} +{{- if ne (toString $env.DB_HOST) (include "suite-stack.postgresHost" $) }} +{{- fail (printf "%s.backend.envVars.DB_HOST must be the shared PostgreSQL, %q (got %q)" $product (include "suite-stack.postgresHost" $) (toString $env.DB_HOST)) }} +{{- end }} +{{- if not (has (toString $env.DB_NAME) $v.bootstrap.postgres.databases) }} +{{- fail (printf "%s.backend.envVars.DB_NAME %q must be listed in bootstrap.postgres.databases" $product (toString $env.DB_NAME)) }} +{{- end }} +{{- if and (ne (toString $env.AWS_STORAGE_BUCKET_NAME) $v.shared.minio.bucket) (not (has (toString $env.AWS_STORAGE_BUCKET_NAME) $v.bootstrap.minio.buckets)) }} +{{- fail (printf "%s.backend.envVars.AWS_STORAGE_BUCKET_NAME %q must be shared.minio.bucket or listed in bootstrap.minio.buckets" $product (toString $env.AWS_STORAGE_BUCKET_NAME)) }} +{{- end }} +{{- if ne (toString $env.OIDC_OP_JWKS_ENDPOINT) (printf "%s/protocol/openid-connect/certs" $issuer) }} +{{- fail (printf "%s.backend.envVars.OIDC_OP_JWKS_ENDPOINT must be under the shared Keycloak's realm %q (got %q)" $product $issuer (toString $env.OIDC_OP_JWKS_ENDPOINT)) }} +{{- end }} +{{- $found := false }} +{{- range $clients }} +{{- if eq .clientId (toString $env.OIDC_RP_CLIENT_ID) }} +{{- $found = true }} +{{- if .public }} +{{- fail (printf "bootstrap.keycloak.clients[%s] must be confidential: a Django product signs in with a secret" .clientId) }} +{{- end }} +{{- if ne .secret (toString $env.OIDC_RP_CLIENT_SECRET) }} +{{- fail (printf "%s.backend.envVars.OIDC_RP_CLIENT_SECRET differs from bootstrap.keycloak.clients[%s].secret" $product .clientId) }} +{{- end }} +{{- if not (has (printf "%s/*" $origin) .redirectUris) }} +{{- fail (printf "bootstrap.keycloak.clients[%s].redirectUris must contain %q" .clientId (printf "%s/*" $origin)) }} +{{- end }} +{{- end }} +{{- end }} +{{- if not $found }} +{{- fail (printf "%s.backend.envVars.OIDC_RP_CLIENT_ID %q has no entry in bootstrap.keycloak.clients" $product (toString $env.OIDC_RP_CLIENT_ID)) }} +{{- end }} +{{- if $v.encryption.enabled }} +{{- $e := $v.encryption }} +{{- if ne (toString $env.ENCRYPTION_FEATURE_ENABLED) "True" }} +{{- fail (printf "%s.backend.envVars.ENCRYPTION_FEATURE_ENABLED must be \"True\" when the encryption service is deployed" $product) }} +{{- end }} +{{- $vault := printf "https://%s" $e.hosts.vault }} +{{- if ne (toString $env.ENCRYPTION_VAULT_URL) $vault }} +{{- fail (printf "%s.backend.envVars.ENCRYPTION_VAULT_URL must be %q (got %q)" $product $vault (toString $env.ENCRYPTION_VAULT_URL)) }} +{{- end }} +{{- $interface := printf "https://%s" $e.hosts.interface }} +{{- if ne (toString $env.ENCRYPTION_INTERFACE_URL) $interface }} +{{- fail (printf "%s.backend.envVars.ENCRYPTION_INTERFACE_URL must be %q (got %q)" $product $interface (toString $env.ENCRYPTION_INTERFACE_URL)) }} +{{- end }} +{{- if not (has $origin $e.config.allowedFrameAncestors) }} +{{- fail (printf "encryption.config.allowedFrameAncestors must contain %q, or the vault refuses to be embedded by %s" $origin $product) }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} + +{{- /* Drive against the document server it opens office files with. */}} +{{- if and $v.onlyoffice.enabled $v.drive.enabled }} +{{- $env := $v.drive.backend.envVars }} +{{- if ne (toString $env.WOPI_CLIENTS) "onlyoffice" }} +{{- fail (printf "drive.backend.envVars.WOPI_CLIENTS must be \"onlyoffice\", the document server deployed with the stack (got %q)" (toString $env.WOPI_CLIENTS)) }} +{{- end }} +{{- if ne (toString $env.WOPI_ONLYOFFICE_DISCOVERY_URL) "http://onlyoffice/hosting/discovery" }} +{{- fail (printf "drive.backend.envVars.WOPI_ONLYOFFICE_DISCOVERY_URL must be \"http://onlyoffice/hosting/discovery\", the document server's Service (got %q)" (toString $env.WOPI_ONLYOFFICE_DISCOVERY_URL)) }} +{{- end }} +{{- $src := printf "https://%s" $v.drive.ingress.host }} +{{- if ne (toString $env.WOPI_SRC_BASE_URL) $src }} +{{- fail (printf "drive.backend.envVars.WOPI_SRC_BASE_URL must be %q, Drive's own origin the document server fetches files from (got %q)" $src (toString $env.WOPI_SRC_BASE_URL)) }} +{{- end }} +{{- if ne (toString $env.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET) $v.onlyoffice.jwtSecret }} +{{- fail "drive.backend.envVars.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET differs from onlyoffice.jwtSecret" }} +{{- end }} +{{- end }} +{{- end }} diff --git a/deploy/helm/suite-stack/templates/_helpers.tpl b/deploy/helm/suite-stack/templates/_helpers.tpl new file mode 100644 index 0000000..2c22642 --- /dev/null +++ b/deploy/helm/suite-stack/templates/_helpers.tpl @@ -0,0 +1,58 @@ +{{- define "suite-stack.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "suite-stack.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "suite-stack.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{ include "suite-stack.selectorLabels" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "suite-stack.selectorLabels" -}} +app.kubernetes.io/name: {{ include "suite-stack.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* The shared services, by the names the product blocks reach them with. */}} +{{- define "suite-stack.postgresHost" -}} +{{- required "shared.postgres.serviceNameOverride is required: the product blocks reach PostgreSQL by that fixed name" .Values.shared.postgres.serviceNameOverride }} +{{- end }} + +{{- define "suite-stack.minioHost" -}} +{{- required "shared.minio.serviceNameOverride is required: the product blocks reach MinIO by that fixed name" .Values.shared.minio.serviceNameOverride }} +{{- end }} + +{{/* The shared chart names Keycloak's Service after its own full name, with no override. */}} +{{- define "suite-stack.keycloakHost" -}} +{{- printf "%s-%s" (required "shared.fullnameOverride is required: the bootstrap Job reaches Keycloak by that fixed name" .Values.shared.fullnameOverride) .Values.shared.keycloak.name | trunc 52 | trimSuffix "-" }} +{{- end }} + +{{- define "suite-stack.issuer" -}} +{{- printf "https://%s/realms/%s" .Values.shared.keycloak.hostname .Values.shared.keycloak.realm.name }} +{{- end }} + +{{- define "suite-stack.encryptionDatabaseUrl" -}} +{{- printf "postgresql://encryption_%s:%s@%s:5432/%s?schema=encryption" .role .password .host .database }} +{{- end }} + +{{/* The image of the encryption service, the way its own chart builds the reference. */}} +{{- define "suite-stack.encryptionImage" -}} +{{- $image := .Values.encryption.image }} +{{- $reference := $image.repository }} +{{- if $image.tag }}{{ $reference = printf "%s:%s" $reference $image.tag }}{{ end }} +{{- if $image.digest }}{{ $reference = printf "%s@%s" $reference $image.digest }}{{ end }} +{{- $reference }} +{{- end }} diff --git a/deploy/helm/suite-stack/templates/bootstrap.yaml b/deploy/helm/suite-stack/templates/bootstrap.yaml new file mode 100644 index 0000000..0803005 --- /dev/null +++ b/deploy/helm/suite-stack/templates/bootstrap.yaml @@ -0,0 +1,265 @@ +{{- include "suite-stack.checks" . -}} +{{- $fullName := include "suite-stack.fullname" . }} +{{- $hookName := printf "%s-bootstrap-%d" $fullName .Release.Revision }} +{{- $b := .Values.bootstrap }} +{{- $s := .Values.shared }} +{{- $postgresHost := include "suite-stack.postgresHost" . }} +{{- $minioHost := include "suite-stack.minioHost" . }} +{{- $keycloakHost := include "suite-stack.keycloakHost" . }} +{{- $realm := $s.keycloak.realm.name }} +{{/* +What the shared chart cannot do on its own, in the order the products need it. The Job +runs after the shared services exist (post-install) and, on an upgrade, before the +products roll out (pre-upgrade), so a changed image migrates before it serves. Every +step is idempotent: the Job also runs on every upgrade, and that is how a tester or a +client gets added later. The scripts hold passwords, hence a Secret, itself a hook of +the same phases so it is never a previous revision's content. +*/}} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $hookName | quote }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: bootstrap + annotations: + helm.sh/hook: post-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +type: Opaque +stringData: + create-migrator-role.sql: | + {{- .Files.Get "files/postgres/create-migrator-role.sql" | nindent 4 }} + create-runtime-role.sql: | + {{- .Files.Get "files/postgres/create-runtime-role.sql" | nindent 4 }} + postgres.sh: | + set -eu + until pg_isready -q; do echo "waiting for PostgreSQL at ${PGHOST}"; sleep 2; done + exists() { [ "$(psql -tAc "$1")" = 1 ]; } + {{- range $b.postgres.databases }} + if ! exists "SELECT 1 FROM pg_database WHERE datname = '{{ . }}'"; then + psql -v ON_ERROR_STOP=1 -c 'CREATE DATABASE "{{ . }}"' + echo "created database {{ . }}" + fi + {{- end }} + {{- if .Values.encryption.enabled }} + # The encryption service's two roles, in its database, from deploy/postgres/. + if ! exists "SELECT 1 FROM pg_roles WHERE rolname = 'encryption_migrator'"; then + psql -d {{ $b.encryption.database | quote }} -v ON_ERROR_STOP=1 -v password="'${ENCRYPTION_MIGRATOR_PASSWORD}'" -f /scripts/create-migrator-role.sql + echo "created role encryption_migrator" + fi + if ! exists "SELECT 1 FROM pg_roles WHERE rolname = 'encryption_runtime'"; then + psql -d {{ $b.encryption.database | quote }} -v ON_ERROR_STOP=1 -v password="'${ENCRYPTION_RUNTIME_PASSWORD}'" -f /scripts/create-runtime-role.sql + echo "created role encryption_runtime" + fi + {{- end }} + minio.sh: | + set -eu + until mc alias set local "http://${MINIO_HOST}:${MINIO_PORT}" "${MINIO_USER}" "${MINIO_PASSWORD}" > /dev/null 2>&1; do + echo "waiting for MinIO at ${MINIO_HOST}:${MINIO_PORT}"; sleep 2 + done + {{- range $b.minio.buckets }} + mc mb --ignore-existing "local/{{ . }}" + {{- if $b.minio.versioning }} + mc version enable "local/{{ . }}" + {{- end }} + {{- end }} + # The terms text as a file: kcadm posts JSON, and the text carries apostrophes the shell + # quoting of the script below could not (the header travels as a variable instead). + notice.json: {{ dict "termsTitle" $b.keycloak.notice.termsTitle "termsText" $b.keycloak.notice.terms | toJson | quote }} + keycloak.sh: | + set -eu + KCADM=/opt/keycloak/bin/kcadm.sh + CONFIG=/tmp/kcadm.config + until "$KCADM" config credentials --config "$CONFIG" --server "http://${KEYCLOAK_HOST}:8080" --realm master --user "${KEYCLOAK_ADMIN}" --password "${KEYCLOAK_ADMIN_PASSWORD}" > /dev/null 2>&1; do + echo "waiting for Keycloak at ${KEYCLOAK_HOST}:8080"; sleep 3 + done + kc() { "$KCADM" "$@" --config "$CONFIG"; } + # The notice: the realm's display name heads every sign-in page, the terms text is a + # message override of the login theme, and the terms step is a default required action, + # so every account registered from now on accepts it before its first sign-in. The + # action's alias is looked up: Keycloak renamed it (lowercase until 20, uppercase after). + kc update "realms/${REALM}" -s "displayName=${NOTICE_HEADER}" -s "displayNameHtml=${NOTICE_HEADER_HTML}" -s internationalizationEnabled=true -s 'supportedLocales=["fr"]' -s defaultLocale=fr + kc create "realms/${REALM}/localization/fr" -f /scripts/notice.json + TERMS=$(kc get authentication/required-actions -r "$REALM" --fields alias --format csv --noquotes | grep -i '^terms_and_conditions$') + kc update "authentication/required-actions/${TERMS}" -r "$REALM" -s enabled=true -s defaultAction=true + # Creates the client, or updates it in place so the URIs follow the values. + ensure_client() { + local client_id=$1 definition=$2 id + id=$(kc get clients -r "$REALM" -q clientId="$client_id" --fields id --format csv --noquotes) + if [ -z "$id" ]; then + echo "$definition" | kc create clients -r "$REALM" -f - + echo "created client ${client_id}" + else + echo "$definition" | kc update "clients/${id}" -r "$REALM" -f - + echo "updated client ${client_id}" + fi + } + # Creates the user if missing, with the terms to accept at first sign-in; the password + # is set every run, so the values win. + ensure_user() { + local username=$1 email=$2 first_name=$3 last_name=$4 password=$5 id + id=$(kc get users -r "$REALM" -q username="$username" -q exact=true --fields id --format csv --noquotes) + if [ -z "$id" ]; then + kc create users -r "$REALM" -s username="$username" -s email="$email" -s firstName="$first_name" -s lastName="$last_name" -s enabled=true -s emailVerified=true -s "requiredActions=[\"${TERMS}\"]" + echo "created user ${username}" + fi + kc set-password -r "$REALM" --username "$username" --new-password "$password" + } + {{- range $b.keycloak.clients }} + {{- $definition := dict "clientId" .clientId "protocol" "openid-connect" "enabled" true "publicClient" (default false .public) "standardFlowEnabled" true "directAccessGrantsEnabled" false "redirectUris" .redirectUris "webOrigins" .webOrigins "attributes" (dict "post.logout.redirect.uris" "+") }} + {{- if not .public }} + {{- $_ := set $definition "secret" (required (printf "bootstrap.keycloak.clients[%s].secret is required for a confidential client" .clientId) .secret) }} + {{- end }} + ensure_client {{ .clientId | squote }} {{ $definition | toJson | squote }} + {{- end }} + {{- range $b.keycloak.users }} + ensure_user {{ .username | squote }} {{ .email | squote }} {{ .firstName | squote }} {{ .lastName | squote }} {{ .password | squote }} + {{- end }} + {{- if .Values.encryption.enabled }} + ENCRYPTION_MIGRATION_DATABASE_URL: {{ include "suite-stack.encryptionDatabaseUrl" (dict "role" "migrator" "password" $b.encryption.migratorPassword "host" $postgresHost "database" $b.encryption.database) | quote }} + {{- end }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $hookName | quote }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: bootstrap + annotations: + helm.sh/hook: post-install,pre-upgrade + helm.sh/hook-weight: "0" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded +spec: + backoffLimit: {{ $b.job.backoffLimit }} + activeDeadlineSeconds: {{ $b.job.activeDeadlineSeconds }} + template: + metadata: + labels: + {{- include "suite-stack.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: bootstrap + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + # Sequential by construction: init containers run one after the other. + initContainers: + - name: postgres + image: {{ $s.postgres.image | quote }} + command: [/bin/sh, /scripts/postgres.sh] + env: + - name: PGHOST + value: {{ $postgresHost | quote }} + - name: PGUSER + value: {{ $s.postgres.username | quote }} + - name: PGPASSWORD + value: {{ $s.postgres.password | quote }} + - name: PGDATABASE + value: {{ $s.postgres.database | quote }} + {{- if .Values.encryption.enabled }} + - name: ENCRYPTION_MIGRATOR_PASSWORD + value: {{ $b.encryption.migratorPassword | quote }} + - name: ENCRYPTION_RUNTIME_PASSWORD + value: {{ $b.encryption.runtimePassword | quote }} + {{- end }} + securityContext: &containerSecurityContext + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: &volumeMounts + - name: scripts + mountPath: /scripts + readOnly: true + - name: tmp + mountPath: /tmp + - name: minio + image: {{ $s.minio.mc.image | quote }} + command: [/bin/sh, /scripts/minio.sh] + env: + - name: MC_CONFIG_DIR + value: /tmp/mc + - name: MINIO_HOST + value: {{ $minioHost | quote }} + - name: MINIO_PORT + value: {{ $s.minio.api.port | quote }} + - name: MINIO_USER + value: {{ $s.minio.username | quote }} + - name: MINIO_PASSWORD + value: {{ $s.minio.password | quote }} + securityContext: *containerSecurityContext + volumeMounts: *volumeMounts + - name: keycloak + image: {{ $s.keycloak.image | quote }} + command: [/bin/bash, /scripts/keycloak.sh] + env: + - name: HOME + value: /tmp + - name: KEYCLOAK_HOST + value: {{ $keycloakHost | quote }} + - name: KEYCLOAK_ADMIN + value: {{ $s.keycloak.username | quote }} + - name: KEYCLOAK_ADMIN_PASSWORD + value: {{ $s.keycloak.password | quote }} + - name: REALM + value: {{ $realm | quote }} + - name: NOTICE_HEADER + value: {{ $b.keycloak.notice.header | quote }} + - name: NOTICE_HEADER_HTML + value: {{ $b.keycloak.notice.header | html | quote }} + securityContext: *containerSecurityContext + volumeMounts: *volumeMounts + containers: + {{- if .Values.encryption.enabled }} + # The service's migration, as its own chart runs it (templates/migration-job.yaml + # there), with the migrator role the first init container created. + - name: migrate + image: {{ include "suite-stack.encryptionImage" . | quote }} + imagePullPolicy: {{ .Values.encryption.image.pullPolicy | default "IfNotPresent" }} + args: + - node_modules/prisma/build/index.js + - migrate + - deploy + env: + - name: NODE_ENV + value: production + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ $hookName | quote }} + key: ENCRYPTION_MIGRATION_DATABASE_URL + securityContext: + <<: *containerSecurityContext + runAsUser: 65532 + runAsGroup: 65532 + volumeMounts: + - name: tmp + mountPath: /tmp + {{- else }} + - name: done + image: {{ $s.postgres.image | quote }} + command: [/bin/sh, -c, 'echo "bootstrap complete"'] + securityContext: *containerSecurityContext + {{- end }} + volumes: + - name: scripts + secret: + secretName: {{ $hookName | quote }} + defaultMode: 0444 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi diff --git a/deploy/helm/suite-stack/templates/mailpit.yaml b/deploy/helm/suite-stack/templates/mailpit.yaml new file mode 100644 index 0000000..e37cf4e --- /dev/null +++ b/deploy/helm/suite-stack/templates/mailpit.yaml @@ -0,0 +1,132 @@ +{{- if .Values.mailpit.enabled }} +{{- $m := .Values.mailpit }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mailpit + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: mailpit +spec: + replicas: 1 + selector: + matchLabels: + {{- include "suite-stack.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: mailpit + template: + metadata: + labels: + {{- include "suite-stack.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: mailpit + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: mailpit + image: {{ $m.image | quote }} + ports: + - name: smtp + containerPort: 1025 + - name: http + containerPort: 8025 + env: + # Messages live in memory: a restart empties the mailbox, which is fine here. + - name: MP_SMTP_AUTH_ACCEPT_ANY + value: 'true' + - name: MP_SMTP_AUTH_ALLOW_INSECURE + value: 'true' + readinessProbe: + httpGet: + path: /livez + port: http + periodSeconds: 5 + livenessProbe: + httpGet: + path: /livez + port: http + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + memory: 256Mi + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi +--- +apiVersion: v1 +kind: Service +metadata: + # A fixed name: the products' SMTP host in the values. + name: mailpit + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: mailpit +spec: + type: ClusterIP + selector: + {{- include "suite-stack.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: mailpit + ports: + - name: smtp + port: 1025 + targetPort: smtp + - name: http + port: 8025 + targetPort: http +{{- if $m.ingress.enabled }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mailpit + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: mailpit + {{- with $m.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with $m.ingress.className }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- if $m.ingress.tls.enabled }} + tls: + - hosts: + - {{ $m.host | quote }} + secretName: {{ $m.ingress.tls.secretName | default "mailpit-tls" | quote }} + {{- end }} + rules: + - host: {{ $m.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: mailpit + port: + name: http +{{- end }} +{{- end }} diff --git a/deploy/helm/suite-stack/templates/onlyoffice.yaml b/deploy/helm/suite-stack/templates/onlyoffice.yaml new file mode 100644 index 0000000..47ed7c7 --- /dev/null +++ b/deploy/helm/suite-stack/templates/onlyoffice.yaml @@ -0,0 +1,152 @@ +{{- if .Values.onlyoffice.enabled }} +{{- $o := .Values.onlyoffice }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: onlyoffice + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: onlyoffice +data: + # Loaded last by the server's configuration stack. Drive fetches the discovery document + # over the Service name, and the server builds the editor URLs of that document from + # the request it receives; `wopi.host` makes it use the public origin instead, the one + # the tester's browser opens. + local-production-linux.json: {{ dict "wopi" (dict "host" (printf "https://%s" $o.host)) | toJson | quote }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: onlyoffice + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: onlyoffice +spec: + replicas: 1 + # One instance, nothing persisted: two would not share the editing sessions. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "suite-stack.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: onlyoffice + template: + metadata: + labels: + {{- include "suite-stack.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: onlyoffice + spec: + automountServiceAccountToken: false + # The image is a whole machine under supervisord (nginx, node services, its own + # PostgreSQL, RabbitMQ and Redis) that starts as root and drops privileges per + # service itself, so none of the hardening of the other pods applies to it. + containers: + - name: onlyoffice + image: {{ $o.image | quote }} + ports: + - name: http + containerPort: 80 + env: + - name: WOPI_ENABLED + value: 'true' + - name: JWT_ENABLED + value: 'true' + - name: JWT_SECRET + value: {{ $o.jwtSecret | quote }} + # The server refuses to fetch documents from private addresses by default; + # inside the cluster that is where Drive is. + - name: ALLOW_PRIVATE_IP_ADDRESS + value: 'true' + # The first boot generates fonts and initializes its database: minutes, not + # seconds. The startup probe covers ten of them. + startupProbe: + httpGet: + path: /healthcheck + port: http + periodSeconds: 10 + failureThreshold: 60 + readinessProbe: + httpGet: + path: /healthcheck + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthcheck + port: http + periodSeconds: 30 + timeoutSeconds: 5 + volumeMounts: + - name: config + mountPath: /etc/onlyoffice/documentserver/local-production-linux.json + subPath: local-production-linux.json + readOnly: true + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + memory: 3Gi + volumes: + - name: config + configMap: + name: onlyoffice +--- +apiVersion: v1 +kind: Service +metadata: + # A fixed name: Drive's discovery and conversion URLs in the values. + name: onlyoffice + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: onlyoffice +spec: + type: ClusterIP + selector: + {{- include "suite-stack.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: onlyoffice + ports: + - name: http + port: 80 + targetPort: http +{{- if $o.ingress.enabled }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: onlyoffice + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "suite-stack.labels" . | nindent 4 }} + app.kubernetes.io/component: onlyoffice + {{- with $o.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with $o.ingress.className }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- if $o.ingress.tls.enabled }} + tls: + - hosts: + - {{ $o.host | quote }} + secretName: {{ $o.ingress.tls.secretName | default "onlyoffice-tls" | quote }} + {{- end }} + rules: + - host: {{ $o.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: onlyoffice + port: + name: http +{{- end }} +{{- end }} diff --git a/deploy/helm/suite-stack/values.schema.json b/deploy/helm/suite-stack/values.schema.json new file mode 100644 index 0000000..a8437fa --- /dev/null +++ b/deploy/helm/suite-stack/values.schema.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "suite-stack values", + "description": "Only the chart's own keys are described: each subchart validates its block (encryption strictly, the others not at all).", + "type": "object", + "properties": { + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" }, + "shared": { "type": "object" }, + "encryption": { "type": "object" }, + "docs": { "type": "object" }, + "drive": { "type": "object" }, + "global": { "type": "object" }, + "bootstrap": { + "type": "object", + "additionalProperties": false, + "required": ["postgres", "minio", "keycloak", "encryption", "job"], + "properties": { + "postgres": { + "type": "object", + "additionalProperties": false, + "required": ["databases"], + "properties": { + "databases": { "type": "array", "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" } } + } + }, + "minio": { + "type": "object", + "additionalProperties": false, + "required": ["buckets"], + "properties": { + "buckets": { "type": "array", "items": { "type": "string", "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$" } }, + "versioning": { "type": "boolean" } + } + }, + "keycloak": { + "type": "object", + "additionalProperties": false, + "required": ["clients", "users", "notice"], + "properties": { + "notice": { + "type": "object", + "additionalProperties": false, + "required": ["header", "termsTitle", "terms"], + "properties": { + "header": { "type": "string", "minLength": 1 }, + "termsTitle": { "type": "string", "minLength": 1 }, + "terms": { "type": "string", "minLength": 1 } + } + }, + "clients": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["clientId", "redirectUris", "webOrigins"], + "properties": { + "clientId": { "type": "string", "minLength": 1 }, + "public": { "type": "boolean" }, + "secret": { "type": "string", "pattern": "^[^'\"]*$" }, + "redirectUris": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^https://" } }, + "webOrigins": { "type": "array", "items": { "type": "string", "pattern": "^https://[^/]+$" } } + } + } + }, + "users": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["username", "email", "firstName", "lastName", "password"], + "properties": { + "username": { "type": "string", "pattern": "^[a-z0-9._-]+$" }, + "email": { "type": "string", "pattern": "^[^'\"@\\s]+@[^'\"@\\s]+$" }, + "firstName": { "type": "string", "pattern": "^[^'\"]+$" }, + "lastName": { "type": "string", "pattern": "^[^'\"]+$" }, + "password": { "type": "string", "minLength": 1, "pattern": "^[^'\"]+$" } + } + } + } + } + }, + "encryption": { + "type": "object", + "additionalProperties": false, + "required": ["database", "migratorPassword", "runtimePassword"], + "properties": { + "database": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "migratorPassword": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._-]+$" }, + "runtimePassword": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._-]+$" } + } + }, + "job": { + "type": "object", + "additionalProperties": false, + "properties": { + "backoffLimit": { "type": "integer", "minimum": 0 }, + "activeDeadlineSeconds": { "type": "integer", "minimum": 1 } + } + } + } + }, + "onlyoffice": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "host", "jwtSecret"], + "properties": { + "enabled": { "type": "boolean" }, + "image": { "type": "string", "minLength": 1 }, + "host": { "type": "string" }, + "jwtSecret": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._-]+$" }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, + "tls": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "secretName": { "type": "string" } + } + } + } + } + } + }, + "mailpit": { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" }, + "image": { "type": "string", "minLength": 1 }, + "host": { "type": "string" }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { "type": "object", "additionalProperties": { "type": "string" } }, + "tls": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "secretName": { "type": "string" } + } + } + } + } + } + } + }, + "additionalProperties": false +} diff --git a/deploy/helm/suite-stack/values.yaml b/deploy/helm/suite-stack/values.yaml new file mode 100644 index 0000000..270f2e2 --- /dev/null +++ b/deploy/helm/suite-stack/values.yaml @@ -0,0 +1,661 @@ +# One complete environment, ready to render: the encryption service, Docs and Drive, and +# the services they share. Every hostname below sits under ONE domain, `suite.example.org`, +# and the chart is meant to be installed in the namespace `suite`. To point it somewhere +# else, replace both strings everywhere in this file (or in your override): the four +# subcharts each read their own block, so a hostname cannot be written once and derived. +# The chart refuses to render when the blocks disagree with each other (see the +# "bootstrap" section and templates/_checks.tpl), so a missed replacement fails early. +# +# Everything here is plaintext and dev grade on purpose: this is a throwaway environment +# for beta testers, backed by the same `dev-backend` chart the products use for their own +# feature environments. Nothing in it is a production pattern. + +# --------------------------------------------------------------------------------------- +# The shared services (the `dev-backend` chart, https://github.com/suitenumerique/helm-dev-backend) +# --------------------------------------------------------------------------------------- +shared: + enabled: true + # Fixed names whatever the release is called: `serviceNameOverride` pins the Service of + # PostgreSQL, Redis and MinIO, which the product blocks below reach as `postgres`, + # `redis` and `minio`. Keycloak has no such override, so its Service is named from the + # chart's full name, `shared-keycloak`; only the bootstrap Job uses it, the products + # sign in through the public hostname. + fullnameOverride: shared + postgres: + enabled: true + name: postgres + serviceNameOverride: postgres + image: postgres:16-alpine + username: dinum + password: pass + # The chart creates this one database at first boot; the products' databases are + # created by the bootstrap Job (bootstrap.postgres.databases). + database: postgres + persistence: true + size: 5Gi + redis: + enabled: true + name: redis + serviceNameOverride: redis + image: redis:8.2-alpine + username: user + password: pass + minio: + enabled: true + name: minio + serviceNameOverride: minio + image: quay.io/minio/minio + mc: + image: quay.io/minio/mc + # Drive's browser uploads go straight to the bucket with presigned URLs, so the API + # needs a public hostname. Docs only reaches it from inside the cluster. + ingress: + enabled: true + hostname: s3.suite.example.org + annotations: + cert-manager.io/cluster-issuer: letsencrypt + tls: + enabled: true + secretName: minio-tls + consoleIngress: + enabled: true + hostname: s3-console.suite.example.org + annotations: + cert-manager.io/cluster-issuer: letsencrypt + tls: + enabled: true + secretName: minio-console-tls + api: + port: 9000 + console: + port: 9001 + username: dinum + password: password + # The chart creates this one bucket; the others come from bootstrap.minio.buckets. + bucket: docs-media-storage + versioning: true + persistence: true + size: 5Gi + keycloak: + enabled: true + name: keycloak + image: quay.io/keycloak/keycloak:20.0.1 + hostname: auth.suite.example.org + annotations: + cert-manager.io/cluster-issuer: letsencrypt + username: admin + password: pass + tls: + enabled: true + secretName: keycloak-tls + db: + username: dinum + password: pass + database: keycloak + persistence: true + # Volume sizes: some block-storage drivers refuse small volumes (Outscale's BSU + # provisions nothing under 4 GiB, and the claim stays Pending with a + # ProvisioningFailed event), so nothing here asks for less than 5Gi. + size: 5Gi + image: postgres:16-alpine + # The realm is imported once, at first boot, with one user and one confidential + # client named after the realm. The clients the products use, and the testers' + # accounts, are created by the bootstrap Job (bootstrap.keycloak), which also runs + # on upgrades, so editing that list is enough to add a tester. + realm: + name: suite + username: john.doe + password: john.doe + email: john.doe@example.org + dsproxy: + enabled: false + cicap: + enabled: false + +# --------------------------------------------------------------------------------------- +# What the shared chart cannot do on its own, run as a Job after the services are up +# (post-install) and before the products roll out on an upgrade (pre-upgrade). +# --------------------------------------------------------------------------------------- +bootstrap: + postgres: + # Created if missing, on the shared PostgreSQL. Each Django product wants its own + # database (both use the `public` schema); the encryption service gets one too, with + # its two roles from deploy/postgres/ (the runtime role can only touch rows). + databases: + - docs + - drive + - encryption + minio: + buckets: + - docs-media-storage + - drive-media-storage + versioning: true + keycloak: + # Created or updated in the realm above, by clientId. `public: true` makes a client + # without secret, for a browser application (the encryption interface). Passwords and + # secrets must not contain quotes. + clients: + - clientId: docs + secret: ThisIsAnExampleKeyForDevPurposeOnly + redirectUris: + - https://docs.suite.example.org/* + webOrigins: + - https://docs.suite.example.org + - clientId: drive + secret: ThisIsAnExampleKeyForDevPurposeOnly + redirectUris: + - https://drive.suite.example.org/* + webOrigins: + - https://drive.suite.example.org + - clientId: encryption + public: true + redirectUris: + - https://encryption.suite.example.org/auth/callback + webOrigins: + - https://encryption.suite.example.org + # The testers. Created if missing, the password is (re)set every run, the email is + # marked verified so every product accepts the account as is. + users: + - username: alice.martin + email: alice.martin@example.org + firstName: Alice + lastName: Martin + password: alice.martin + - username: bob.dupont + email: bob.dupont@example.org + firstName: Bob + lastName: Dupont + password: bob.dupont + # Written on the realm at every run. `header` heads every sign-in and registration + # page; `terms` is what every new account, registered or created here, must accept + # before its first sign-in (Keycloak's own terms-and-conditions step). Anyone with the + # URL can register, the admin console and the mailbox are public, so nothing here is + # private: the tester reads that before typing anything real. + notice: + header: "Environnement de test : rien n'y est privé" + termsTitle: Environnement de test + terms: >- + Cet environnement sert à essayer LaSuite. Tout le monde peut y créer un compte, la + console d'administration et la boîte mail sont publiques : un compte peut être lu ou + repris par n'importe qui. N'y mettez aucune donnée réelle, ni document, ni mot de + passe utilisé ailleurs. Tout peut être effacé à tout moment. + encryption: + # The service's database and the passwords of its two roles. The chart checks that + # `encryption.database.url` below is the runtime role's URL built from these, and + # runs the migration itself with the migrator's, since the service's own migration + # hook would run before the shared PostgreSQL exists. + database: encryption + migratorPassword: migrator + runtimePassword: runtime + job: + backoffLimit: 2 + activeDeadlineSeconds: 900 + +# A mail catcher: the products and the encryption service send there, testers read the +# mailbox on the web UI. The encryption service refuses to boot without an SMTP host. +mailpit: + enabled: true + image: axllent/mailpit:v1.31.1 + host: mail.suite.example.org + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + tls: + enabled: true + secretName: '' + +# The document server Drive opens office files with (WOPI): a text document, a spreadsheet +# or a slide deck is edited in the browser, by several testers at once. Without it Drive +# only offers the download. Docs has its own editor and does not use it. One container +# with everything inside (its own PostgreSQL, RabbitMQ, Redis), nothing persisted: a +# restart loses the editing sessions that were open, not the files, which are in MinIO. +onlyoffice: + enabled: true + image: onlyoffice/documentserver:9.4.0.1 + host: office.suite.example.org + # Signs the conversion calls Drive makes to the server; the same value goes in + # drive.backend.envVars.WOPI_ONLYOFFICE_CONVERT_JWT_SECRET. + jwtSecret: ThisIsAnExampleKeyForDevPurposeOnly + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + # The editor holds a WebSocket open for the whole session. + nginx.ingress.kubernetes.io/proxy-read-timeout: '3600' + nginx.ingress.kubernetes.io/proxy-send-timeout: '3600' + nginx.ingress.kubernetes.io/proxy-body-size: 100m + tls: + enabled: true + secretName: '' + +# --------------------------------------------------------------------------------------- +# The encryption service (deploy/helm/encryption, every key documented there) +# --------------------------------------------------------------------------------------- +encryption: + enabled: true + image: + repository: lasuite/encryption + # `main` is republished on every push to the CI branch; a tag pins a release. + tag: main + pullPolicy: Always + replicaCount: 1 + hosts: + vault: data.encryption.suite.example.org + interface: encryption.suite.example.org + config: + allowedFrameAncestors: + - https://docs.suite.example.org + - https://drive.suite.example.org + oidc: + issuer: https://auth.suite.example.org/realms/suite + jwksUrl: https://auth.suite.example.org/realms/suite/protocol/openid-connect/certs + clientId: encryption + # Accounts added by hand in the Keycloak console rarely have their email marked + # verified; the bootstrap accounts always do. + acceptUnverifiedEmail: true + mailer: + smtpHost: mailpit + smtpPort: 1025 + secure: false + requireTls: false + senderAddress: noreply@suite.example.org + emailProductUrl: https://docs.suite.example.org + database: + url: postgresql://encryption_runtime:runtime@postgres:5432/encryption?schema=encryption + migration: + # Run by the bootstrap Job instead (see bootstrap.encryption). + enabled: false + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + +# --------------------------------------------------------------------------------------- +# Docs (the `docs` chart, src/helm/impress in https://github.com/suitenumerique/docs). +# Copied from the chart's own feature-environment values, with every coordinate pointed at +# the shared services and the three encryption variables added. +# --------------------------------------------------------------------------------------- +docs: + enabled: true + # Fixed names for the in-cluster Services (`docs-backend`, `docs-y-provider`...). + fullnameOverride: docs + image: + repository: lasuite/impress-backend + pullPolicy: Always + tag: main + backend: + migrateJobAnnotations: + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded + # The chart's migrate and createsuperuser Job pods carry the same labels as the + # backend pods, so this PodDisruptionBudget (maxUnavailable: 1) selects them too, and + # the disruption controller fails on them ("jobs.batch does not implement the scale + # subresource"). The budget is then Degraded, and Argo CD, which waits for every + # object to be healthy before the PostSync hooks, fails the sync. One replica has no + # use for a budget anyway. + pdb: + enabled: false + replicas: 1 + envVars: + DJANGO_CONFIGURATION: Feature + DJANGO_SETTINGS_MODULE: impress.settings + DJANGO_SECRET_KEY: lkjsdlfkjsldkfjslkdfjslkdjfslkdjf + DJANGO_ALLOWED_HOSTS: docs.suite.example.org + DJANGO_CSRF_TRUSTED_ORIGINS: https://docs.suite.example.org + DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key + DJANGO_SUPERUSER_PASSWORD: admin + DJANGO_EMAIL_BRAND_NAME: 'La Suite Numérique' + DJANGO_EMAIL_HOST: mailpit + DJANGO_EMAIL_PORT: 1025 + DJANGO_EMAIL_USE_SSL: 'False' + DJANGO_EMAIL_LOGO_IMG: https://docs.suite.example.org/assets/logo-suite-numerique.png + DJANGO_EMAIL_URL_APP: https://docs.suite.example.org + FRONTEND_SILENT_LOGIN_ENABLED: 'True' + LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR + LOGGING_LEVEL_LOGGERS_ROOT: INFO + LOGGING_LEVEL_LOGGERS_APP: INFO + OIDC_USERINFO_SHORTNAME_FIELD: first_name + OIDC_USERINFO_FULLNAME_FIELDS: name + OIDC_OP_JWKS_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/certs + OIDC_OP_AUTHORIZATION_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/auth + OIDC_OP_TOKEN_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/token + OIDC_OP_USER_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/userinfo + OIDC_OP_LOGOUT_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/logout + OIDC_REDIRECT_ALLOWED_HOSTS: docs.suite.example.org + OIDC_RP_CLIENT_ID: docs + OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly + OIDC_RP_SIGN_ALGO: RS256 + OIDC_RP_SCOPES: openid email profile + # The encryption integration reads the access token from the session. + OIDC_STORE_ACCESS_TOKEN: 'True' + LOGIN_REDIRECT_URL: https://docs.suite.example.org + LOGIN_REDIRECT_URL_FAILURE: https://docs.suite.example.org + LOGOUT_REDIRECT_URL: https://docs.suite.example.org + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: docs + DB_USER: dinum + DB_PASSWORD: pass + # One Redis, one database index per product. + REDIS_URL: redis://user:pass@redis:6379/1 + DJANGO_CELERY_BROKER_URL: redis://user:pass@redis:6379/1 + AWS_S3_ENDPOINT_URL: http://minio:9000 + AWS_S3_ACCESS_KEY_ID: dinum + AWS_S3_SECRET_ACCESS_KEY: password + AWS_STORAGE_BUCKET_NAME: docs-media-storage + STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + COLLABORATION_SERVER_SECRET: my-secret + Y_PROVIDER_API_BASE_URL: http://docs-y-provider:443/api/ + Y_PROVIDER_API_KEY: my-secret + DOCSPEC_API_URL: http://docs-docspec:4000/conversion + ENCRYPTION_FEATURE_ENABLED: 'True' + ENCRYPTION_VAULT_URL: https://data.encryption.suite.example.org + ENCRYPTION_INTERFACE_URL: https://encryption.suite.example.org + # The migration Job is not a hook: it starts with the rest of the release, so it + # waits for the shared PostgreSQL (and for the bootstrap Job's database) itself. + migrate: + command: + - /bin/sh + - -c + - | + while ! python manage.py check --database default > /dev/null 2>&1 + do + echo "Database not ready" + sleep 2 + done + echo "Database is ready" + python manage.py migrate --no-input + restartPolicy: Never + createsuperuser: + command: + - /bin/sh + - -c + - | + while ! python manage.py check --database default > /dev/null 2>&1 + do + echo "Database not ready" + sleep 2 + done + python manage.py createsuperuser --email admin@example.org --password admin + restartPolicy: Never + frontend: + replicas: 1 + image: + repository: lasuite/impress-frontend + pullPolicy: Always + tag: main + envVars: + PORT: 8080 + NEXT_PUBLIC_API_ORIGIN: https://docs.suite.example.org + yProvider: + replicas: 1 + image: + repository: lasuite/impress-y-provider + pullPolicy: Always + tag: main + envVars: + COLLABORATION_BACKEND_BASE_URL: https://docs.suite.example.org + COLLABORATION_LOGGING: 'true' + COLLABORATION_SERVER_ORIGIN: https://docs.suite.example.org + COLLABORATION_SERVER_SECRET: my-secret + Y_PROVIDER_API_KEY: my-secret + docSpec: + enabled: true + replicas: 1 + image: + repository: ghcr.io/docspecio/api + pullPolicy: IfNotPresent + tag: 2.6.3 + probes: + liveness: + path: /health + readiness: + path: /health + ingress: + enabled: true + host: docs.suite.example.org + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 20m + cert-manager.io/cluster-issuer: letsencrypt + ingressCollaborationWS: + enabled: true + host: docs.suite.example.org + ingressCollaborationApi: + enabled: true + host: docs.suite.example.org + ingressAdmin: + enabled: true + host: docs.suite.example.org + ingressMedia: + enabled: true + host: docs.suite.example.org + annotations: + nginx.ingress.kubernetes.io/auth-url: https://docs.suite.example.org/api/v1.0/documents/media-auth/ + nginx.ingress.kubernetes.io/auth-response-headers: 'Authorization, X-Amz-Date, X-Amz-Content-SHA256' + nginx.ingress.kubernetes.io/upstream-vhost: minio.suite.svc.cluster.local:9000 + nginx.ingress.kubernetes.io/rewrite-target: /docs-media-storage/$1 + # An ExternalName Service, so it needs the fully qualified name of the shared MinIO. + serviceMedia: + host: minio.suite.svc.cluster.local + port: 9000 + posthog: + ingress: + enabled: false + ingressAssets: + enabled: false + +# --------------------------------------------------------------------------------------- +# Drive (the `drive` chart, src/helm/drive in https://github.com/suitenumerique/drive). +# Same origin as the Docs block: the chart's development values. +# --------------------------------------------------------------------------------------- +drive: + enabled: true + fullnameOverride: drive + image: + repository: lasuite/drive-backend + pullPolicy: Always + tag: main + backend: + migrateJobAnnotations: + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded + replicas: 1 + envVars: + DJANGO_CONFIGURATION: Feature + DJANGO_SETTINGS_MODULE: drive.settings + DJANGO_SECRET_KEY: lkjsdlfkjsldkfjslkdfjslkdjfslkdjf + DJANGO_ALLOWED_HOSTS: drive.suite.example.org + DJANGO_CSRF_TRUSTED_ORIGINS: https://drive.suite.example.org + DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key + DJANGO_SUPERUSER_PASSWORD: admin + DJANGO_EMAIL_BRAND_NAME: 'La Suite Numérique' + DJANGO_EMAIL_HOST: mailpit + DJANGO_EMAIL_PORT: 1025 + DJANGO_EMAIL_USE_SSL: 'False' + DJANGO_EMAIL_LOGO_IMG: https://drive.suite.example.org/assets/logo-suite-numerique.png + LOGGING_LEVEL_LOGGERS_ROOT: INFO + LOGGING_LEVEL_LOGGERS_APP: INFO + OIDC_OP_JWKS_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/certs + OIDC_OP_AUTHORIZATION_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/auth + OIDC_OP_TOKEN_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/token + OIDC_OP_USER_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/userinfo + OIDC_OP_LOGOUT_ENDPOINT: https://auth.suite.example.org/realms/suite/protocol/openid-connect/logout + OIDC_RP_CLIENT_ID: drive + OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly + OIDC_RP_SIGN_ALGO: RS256 + OIDC_RP_SCOPES: openid email + OIDC_USER_FIELD_TO_SHORTNAME: given_name + OIDC_USER_FIELDS_TO_FULLNAME: given_name,usual_name + OIDC_STORE_ACCESS_TOKEN: 'True' + LOGIN_REDIRECT_URL: https://drive.suite.example.org + LOGIN_REDIRECT_URL_FAILURE: https://drive.suite.example.org + LOGOUT_REDIRECT_URL: https://drive.suite.example.org + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: drive + DB_USER: dinum + DB_PASSWORD: pass + REDIS_URL: redis://user:pass@redis:6379/2 + DJANGO_CELERY_BROKER_URL: redis://user:pass@redis:6379/2 + # Public: the browser uploads to presigned URLs on it. + AWS_S3_ENDPOINT_URL: https://s3.suite.example.org + AWS_S3_ACCESS_KEY_ID: dinum + AWS_S3_SECRET_ACCESS_KEY: password + AWS_S3_SIGNATURE_VERSION: s3v4 + AWS_S3_REGION_NAME: eu-east-1 + AWS_STORAGE_BUCKET_NAME: drive-media-storage + STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + MEDIA_BASE_URL: https://drive.suite.example.org + DJANGO_CELERY_TASK_ROUTES: "{'core.tasks.storage.mirror_file': {'queue': 'mirror'}, 'lasuite.malware_detection.tasks.*': {'queue': 'malware_detection'}}" + ENCRYPTION_FEATURE_ENABLED: 'True' + ENCRYPTION_VAULT_URL: https://data.encryption.suite.example.org + ENCRYPTION_INTERFACE_URL: https://encryption.suite.example.org + # The document server (onlyoffice below): discovered and called over its Service, + # while it fetches the files from Drive's public origin. + WOPI_CLIENTS: onlyoffice + WOPI_ONLYOFFICE_DISCOVERY_URL: http://onlyoffice/hosting/discovery + WOPI_SRC_BASE_URL: https://drive.suite.example.org + WOPI_ONLYOFFICE_OPTIONS: '{"ForceConvertExtensions": ["doc", "xls", "ppt"], "ConvertServiceUrl": "http://onlyoffice/converter"}' + WOPI_ONLYOFFICE_CONVERT_JWT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly + # Drive learns the server's capabilities and proof keys by a Celery task, run by its + # configure-wopi Job and by a schedule, daily at 3:00 by default. Every five minutes + # here: the Job is a PostSync hook under Argo CD, which waits for the whole stack to + # be healthy, and the server mints new proof keys at each restart (nothing is + # persisted), after which Drive's cached ones are refused until the next run. + WOPI_CONFIGURATION_CRONTAB_MINUTE: '*/5' + WOPI_CONFIGURATION_CRONTAB_HOUR: '*' + migrate: + command: + - /bin/sh + - -c + - | + while ! python manage.py check --database default > /dev/null 2>&1 + do + echo "Database not ready" + sleep 2 + done + echo "Database is ready" + python manage.py migrate --no-input + restartPolicy: Never + createsuperuser: + command: + - /bin/sh + - -c + - | + while ! python manage.py check --database default > /dev/null 2>&1 + do + echo "Database not ready" + sleep 2 + done + python manage.py createsuperuser --email admin@example.org --password admin + restartPolicy: Never + # The chart's Job enqueues one Celery task that reads the document server's discovery + # document, with no retry: run before the server answers, the editor stays unconfigured + # until the daily refresh. So it waits, like the migration waits for the database. + configureWopi: + command: + - /bin/sh + - -c + - | + until python -c "import urllib.request; urllib.request.urlopen('$WOPI_ONLYOFFICE_DISCOVERY_URL', timeout=5)" > /dev/null 2>&1 + do + echo "Document server not ready" + sleep 5 + done + python manage.py trigger_wopi_configuration + restartPolicy: Never + celeryWorkers: + - name: default + replicas: 1 + command: [] + args: ['celery', '-A', 'drive.celery_app', 'worker', '-l', 'INFO', '-n', 'drive@%h'] + resources: {} + probes: + liveness: + exec: + command: ['/bin/sh', '-c', 'celery -A drive.celery_app inspect ping -d drive@$HOSTNAME'] + initialDelaySeconds: 60 + timeoutSeconds: 5 + readiness: + exec: + command: ['/bin/sh', '-c', 'celery -A drive.celery_app inspect ping -d drive@$HOSTNAME'] + initialDelaySeconds: 15 + timeoutSeconds: 5 + - name: mirror + replicas: 1 + command: [] + args: ['celery', '-A', 'drive.celery_app', 'worker', '-l', 'INFO', '-Q', 'mirror', '-n', 'drive@%h'] + resources: {} + probes: + liveness: + exec: + command: ['/bin/sh', '-c', 'celery -A drive.celery_app inspect ping -d drive@$HOSTNAME'] + initialDelaySeconds: 60 + timeoutSeconds: 5 + readiness: + exec: + command: ['/bin/sh', '-c', 'celery -A drive.celery_app inspect ping -d drive@$HOSTNAME'] + initialDelaySeconds: 15 + timeoutSeconds: 5 + - name: malware-detection + replicas: 1 + command: [] + args: ['celery', '-A', 'drive.celery_app', 'worker', '-l', 'INFO', '-Q', 'malware_detection', '-n', 'drive@%h'] + resources: {} + probes: + liveness: + exec: + command: ['/bin/sh', '-c', 'celery -A drive.celery_app inspect ping -d drive@$HOSTNAME'] + initialDelaySeconds: 60 + timeoutSeconds: 5 + readiness: + exec: + command: ['/bin/sh', '-c', 'celery -A drive.celery_app inspect ping -d drive@$HOSTNAME'] + initialDelaySeconds: 15 + timeoutSeconds: 5 + frontend: + replicas: 1 + image: + repository: lasuite/drive-frontend + pullPolicy: Always + tag: main + envVars: + PORT: 8080 + NEXT_PUBLIC_API_ORIGIN: https://drive.suite.example.org + ingress: + enabled: true + host: drive.suite.example.org + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 150m + cert-manager.io/cluster-issuer: letsencrypt + ingressAdmin: + enabled: true + host: drive.suite.example.org + ingressMedia: + enabled: true + host: drive.suite.example.org + annotations: + nginx.ingress.kubernetes.io/auth-url: https://drive.suite.example.org/api/v1.0/items/media-auth/ + nginx.ingress.kubernetes.io/auth-response-headers: 'Authorization, X-Amz-Date, X-Amz-Content-SHA256' + nginx.ingress.kubernetes.io/upstream-vhost: minio.suite.svc.cluster.local:9000 + nginx.ingress.kubernetes.io/rewrite-target: /drive-media-storage/$1 + ingressMediaPreview: + enabled: true + host: drive.suite.example.org + annotations: + nginx.ingress.kubernetes.io/auth-url: https://drive.suite.example.org/api/v1.0/items/media-auth/ + nginx.ingress.kubernetes.io/auth-response-headers: 'Authorization, X-Amz-Date, X-Amz-Content-SHA256' + nginx.ingress.kubernetes.io/upstream-vhost: minio.suite.svc.cluster.local:9000 + nginx.ingress.kubernetes.io/rewrite-target: /drive-media-storage/$1 + serviceMedia: + host: minio.suite.svc.cluster.local + port: 9000 + posthog: + ingress: + enabled: false + ingressAssets: + enabled: false diff --git a/deploy/helmfile/preview/helmfile.yaml.gotmpl b/deploy/helmfile/preview/helmfile.yaml.gotmpl new file mode 100644 index 0000000..d2715a9 --- /dev/null +++ b/deploy/helmfile/preview/helmfile.yaml.gotmpl @@ -0,0 +1,75 @@ +# The stack as one environment derived from two inputs: every hostname comes from +# `feature` (a short label: `beta` for the testers' long-lived environment, a pull +# request number for an ephemeral preview) and `domain`, and the chart is the one of +# this checkout, never a published one. This is the factorization the chart cannot do +# itself (each subchart reads its own block of values), so whoever deploys it only sets +# those two inputs, as helmfile state values or as the variables FEATURE and DOMAIN: +# +# helmfile -e preview -f deploy/helmfile/preview/helmfile.yaml.gotmpl \ +# --state-values-set feature=2694,domain=ppr.example.org \ +# --state-values-set images.docs.tag=pr-2694,images.docsFrontend.tag=pr-2694 \ +# apply +# +# FEATURE=2694 DOMAIN=ppr.example.org \ +# helmfile -e preview -f deploy/helmfile/preview/helmfile.yaml.gotmpl apply +# +# The variables exist for Argo CD: its ApplicationSet passes the pull request number and +# the domain to its helmfile plugin as environment variables of the Application, not as +# command-line options, and a sidecar plugin sees them prefixed ARGOCD_ENV_. A state +# value wins over the variable, the variable over the default below. +# +# Hostnames come out as -docs., -drive., +# -encryption. and data.-encryption. (the vault must +# start with `data.`), -auth, -s3, -s3-console, -mail +# and -office. One wildcard DNS record on covers them all, and cert-manager +# issues one certificate per host. The namespace is the one given (`-n`, or the Argo CD +# destination), else `preview-`. +{{- $feature := coalesce (env "ARGOCD_ENV_FEATURE") (env "FEATURE") "ci" }} +{{- $domain := coalesce (env "ARGOCD_ENV_DOMAIN") (env "DOMAIN") "example.org" }} +environments: + preview: + values: + - feature: {{ $feature | quote }} + domain: {{ $domain | quote }} + # The images of the branches under test. The encryption `main` tag follows the CI + # branch; the products' tags are either the `pr-` ones their CI pushes for + # a labelled upstream pull request, or a build pushed by hand (see the stack + # README, "Images"). Either way the reference is set here, on our side. + images: + encryption: + repository: lasuite/encryption + tag: main + docs: + repository: lasuite/impress-backend + tag: main + docsFrontend: + repository: lasuite/impress-frontend + tag: main + docsYProvider: + repository: lasuite/impress-y-provider + tag: main + drive: + repository: lasuite/drive-backend + tag: main + driveFrontend: + repository: lasuite/drive-frontend + tag: main + ingressClassName: nginx + clusterIssuer: letsencrypt +--- +# Registered before the render so `helm dependency build` resolves the stack chart's +# dependencies (a Chart.lock names repositories by URL and Helm wants them known). +repositories: + - name: suitenumerique-docs + url: https://suitenumerique.github.io/docs + - name: suitenumerique-drive + url: https://suitenumerique.github.io/drive + - name: suitenumerique-dev-backend + url: https://suitenumerique.github.io/helm-dev-backend +--- +releases: + - name: stack + namespace: {{ .Namespace | default (printf "preview-%v" .Values.feature) }} + chart: ../../helm/suite-stack + values: + - values.yaml.gotmpl diff --git a/deploy/helmfile/preview/values.yaml.gotmpl b/deploy/helmfile/preview/values.yaml.gotmpl new file mode 100644 index 0000000..877b5bd --- /dev/null +++ b/deploy/helmfile/preview/values.yaml.gotmpl @@ -0,0 +1,195 @@ +{{- $f := .Values.feature | toString -}} +{{- $d := .Values.domain -}} +{{- $ns := .Namespace | default (printf "preview-%s" $f) -}} +{{- $docs := printf "%s-docs.%s" $f $d -}} +{{- $drive := printf "%s-drive.%s" $f $d -}} +{{- $interface := printf "%s-encryption.%s" $f $d -}} +{{- $vault := printf "data.%s-encryption.%s" $f $d -}} +{{- $auth := printf "%s-auth.%s" $f $d -}} +{{- $s3 := printf "%s-s3.%s" $f $d -}} +{{- $issuer := printf "https://%s/realms/suite" $auth -}} +{{- $minio := printf "minio.%s.svc.cluster.local" $ns -}} +{{- $i := .Values.images -}} +# Rendered by helmfile on top of the chart's values.yaml: only what carries a hostname, the +# namespace or an image is here, every other value stays the chart's default. Lists are +# replaced whole, not merged, so the ones that carry a hostname are given in full. +shared: + minio: + ingress: + hostname: {{ $s3 }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.clusterIssuer }} + consoleIngress: + hostname: {{ $f }}-s3-console.{{ $d }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.clusterIssuer }} + keycloak: + hostname: {{ $auth }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.clusterIssuer }} + +bootstrap: + keycloak: + clients: + - clientId: docs + secret: ThisIsAnExampleKeyForDevPurposeOnly + redirectUris: + - https://{{ $docs }}/* + webOrigins: + - https://{{ $docs }} + - clientId: drive + secret: ThisIsAnExampleKeyForDevPurposeOnly + redirectUris: + - https://{{ $drive }}/* + webOrigins: + - https://{{ $drive }} + - clientId: encryption + public: true + redirectUris: + - https://{{ $interface }}/auth/callback + webOrigins: + - https://{{ $interface }} + +mailpit: + host: {{ $f }}-mail.{{ $d }} + ingress: + className: {{ .Values.ingressClassName }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.clusterIssuer }} + +onlyoffice: + host: {{ $f }}-office.{{ $d }} + ingress: + className: {{ .Values.ingressClassName }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.clusterIssuer }} + +encryption: + image: + repository: {{ $i.encryption.repository }} + tag: {{ $i.encryption.tag | quote }} + hosts: + vault: {{ $vault }} + interface: {{ $interface }} + config: + allowedFrameAncestors: + - https://{{ $docs }} + - https://{{ $drive }} + oidc: + issuer: {{ $issuer }} + jwksUrl: {{ $issuer }}/protocol/openid-connect/certs + mailer: + senderAddress: noreply@{{ $d }} + emailProductUrl: https://{{ $docs }} + ingress: + className: {{ .Values.ingressClassName }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.clusterIssuer }} + +docs: + image: + repository: {{ $i.docs.repository }} + tag: {{ $i.docs.tag | quote }} + backend: + envVars: + DJANGO_ALLOWED_HOSTS: {{ $docs }} + DJANGO_CSRF_TRUSTED_ORIGINS: https://{{ $docs }} + DJANGO_EMAIL_LOGO_IMG: https://{{ $docs }}/assets/logo-suite-numerique.png + DJANGO_EMAIL_URL_APP: https://{{ $docs }} + OIDC_OP_JWKS_ENDPOINT: {{ $issuer }}/protocol/openid-connect/certs + OIDC_OP_AUTHORIZATION_ENDPOINT: {{ $issuer }}/protocol/openid-connect/auth + OIDC_OP_TOKEN_ENDPOINT: {{ $issuer }}/protocol/openid-connect/token + OIDC_OP_USER_ENDPOINT: {{ $issuer }}/protocol/openid-connect/userinfo + OIDC_OP_LOGOUT_ENDPOINT: {{ $issuer }}/protocol/openid-connect/logout + OIDC_REDIRECT_ALLOWED_HOSTS: {{ $docs }} + LOGIN_REDIRECT_URL: https://{{ $docs }} + LOGIN_REDIRECT_URL_FAILURE: https://{{ $docs }} + LOGOUT_REDIRECT_URL: https://{{ $docs }} + ENCRYPTION_VAULT_URL: https://{{ $vault }} + ENCRYPTION_INTERFACE_URL: https://{{ $interface }} + frontend: + image: + repository: {{ $i.docsFrontend.repository }} + tag: {{ $i.docsFrontend.tag | quote }} + envVars: + NEXT_PUBLIC_API_ORIGIN: https://{{ $docs }} + yProvider: + image: + repository: {{ $i.docsYProvider.repository }} + tag: {{ $i.docsYProvider.tag | quote }} + envVars: + COLLABORATION_BACKEND_BASE_URL: https://{{ $docs }} + COLLABORATION_SERVER_ORIGIN: https://{{ $docs }} + ingress: + className: {{ .Values.ingressClassName }} + host: {{ $docs }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.clusterIssuer }} + ingressCollaborationWS: + className: {{ .Values.ingressClassName }} + host: {{ $docs }} + ingressCollaborationApi: + className: {{ .Values.ingressClassName }} + host: {{ $docs }} + ingressAdmin: + className: {{ .Values.ingressClassName }} + host: {{ $docs }} + ingressMedia: + className: {{ .Values.ingressClassName }} + host: {{ $docs }} + annotations: + nginx.ingress.kubernetes.io/auth-url: https://{{ $docs }}/api/v1.0/documents/media-auth/ + nginx.ingress.kubernetes.io/upstream-vhost: {{ $minio }}:9000 + serviceMedia: + host: {{ $minio }} + +drive: + image: + repository: {{ $i.drive.repository }} + tag: {{ $i.drive.tag | quote }} + backend: + envVars: + DJANGO_ALLOWED_HOSTS: {{ $drive }} + DJANGO_CSRF_TRUSTED_ORIGINS: https://{{ $drive }} + DJANGO_EMAIL_LOGO_IMG: https://{{ $drive }}/assets/logo-suite-numerique.png + OIDC_OP_JWKS_ENDPOINT: {{ $issuer }}/protocol/openid-connect/certs + OIDC_OP_AUTHORIZATION_ENDPOINT: {{ $issuer }}/protocol/openid-connect/auth + OIDC_OP_TOKEN_ENDPOINT: {{ $issuer }}/protocol/openid-connect/token + OIDC_OP_USER_ENDPOINT: {{ $issuer }}/protocol/openid-connect/userinfo + OIDC_OP_LOGOUT_ENDPOINT: {{ $issuer }}/protocol/openid-connect/logout + LOGIN_REDIRECT_URL: https://{{ $drive }} + LOGIN_REDIRECT_URL_FAILURE: https://{{ $drive }} + LOGOUT_REDIRECT_URL: https://{{ $drive }} + AWS_S3_ENDPOINT_URL: https://{{ $s3 }} + MEDIA_BASE_URL: https://{{ $drive }} + ENCRYPTION_VAULT_URL: https://{{ $vault }} + ENCRYPTION_INTERFACE_URL: https://{{ $interface }} + WOPI_SRC_BASE_URL: https://{{ $drive }} + frontend: + image: + repository: {{ $i.driveFrontend.repository }} + tag: {{ $i.driveFrontend.tag | quote }} + envVars: + NEXT_PUBLIC_API_ORIGIN: https://{{ $drive }} + ingress: + className: {{ .Values.ingressClassName }} + host: {{ $drive }} + annotations: + cert-manager.io/cluster-issuer: {{ .Values.clusterIssuer }} + ingressAdmin: + className: {{ .Values.ingressClassName }} + host: {{ $drive }} + ingressMedia: + className: {{ .Values.ingressClassName }} + host: {{ $drive }} + annotations: + nginx.ingress.kubernetes.io/auth-url: https://{{ $drive }}/api/v1.0/items/media-auth/ + nginx.ingress.kubernetes.io/upstream-vhost: {{ $minio }}:9000 + ingressMediaPreview: + className: {{ .Values.ingressClassName }} + host: {{ $drive }} + annotations: + nginx.ingress.kubernetes.io/auth-url: https://{{ $drive }}/api/v1.0/items/media-auth/ + nginx.ingress.kubernetes.io/upstream-vhost: {{ $minio }}:9000 + serviceMedia: + host: {{ $minio }} From 116ac3d731d51504d5620064ca15468606f541d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 23 Sep 2026 12:28:01 +0200 Subject: [PATCH 08/47] chore(deploy): allow pushing images from preview pull requests and manually from any branch --- .github/actions/build-image/action.yml | 69 ++++++ .github/workflows/ci.yml | 51 +---- .github/workflows/preview.yml | 209 ++++++++++++++++-- CLAUDE.md | 2 +- .../applicationset-preview.example.yaml | 17 +- deploy/helm/suite-stack.test.ts | 12 +- deploy/helm/suite-stack/README.md | 6 +- deploy/helmfile/preview/helmfile.yaml.gotmpl | 25 ++- 8 files changed, 316 insertions(+), 75 deletions(-) create mode 100644 .github/actions/build-image/action.yml diff --git a/.github/actions/build-image/action.yml b/.github/actions/build-image/action.yml new file mode 100644 index 0000000..2f2e4e3 --- /dev/null +++ b/.github/actions/build-image/action.yml @@ -0,0 +1,69 @@ +name: Build the image +description: >- + Build the application image with its provenance and SBOM, load it in a containerd image + store, and scan it. Nothing is pushed: the caller logs in and pushes what passed, so a + release and a preview build the same way and differ only in where the image goes. + +inputs: + tags: + description: Comma-separated image references to build (registry/repository:tag) + required: true + primary-tag: + description: The reference among `tags` that is scanned and whose digest the caller reads + required: true + source-commit: + description: The commit the image is built from, recorded in the image + required: true +runs: + using: composite + steps: + - name: Set up Docker + if: ${{ !github.event.act }} # reconfigures the daemon, which act reaches through a mounted socket + uses: docker/setup-docker-action@77e84dbf09b47d1e29270283c22f16145aa85ca1 # v5.4.0 + with: + version: v29.7.2 # pinned like the rest, this job is the one holding a credential + # containerd store needed to let the build be scanned (provenance and SBOM) and then pushed + daemon-config: | + { + "features": { + "containerd-snapshotter": true + } + } + + - name: Make sure the containerd image store is active # needed to ensure `load: true` because otherwise attestation and provenance are dropped silently + if: ${{ !github.event.act }} # asserts the runner daemon, not whatever a developer happens to run + shell: bash + run: | + docker info --format '{{json .DriverStatus}}' | grep -q 'io.containerd.snapshotter' \ + || (echo "::error::The containerd image store is not active, attestations would be dropped." && exit 1) + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build the image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + load: true + push: false + tags: ${{ inputs.tags }} + build-args: | + SOURCE_COMMIT=${{ inputs.source-commit }} + provenance: mode=max # attach repository info to the image + sbom: true # store attestation manifest inside the image index (including info from application `.cdx.json` files) + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Scan the image + # It will scan the Docker attached SBOM, but also the npm ones copied inside as `.cdx.json` + # "act" cannot support this since trivy action is doing `actions/checkout`, which needs a token act has none of + if: ${{ !github.event.act }} + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + TRIVY_VEX: security/vex.openvex.json # allow bypass some if documented (opt-in file for external scanners, so fine if manipulated) + with: + image-ref: ${{ inputs.primary-tag }} + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: '1' + format: table diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 406f439..f5b8af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,28 +288,6 @@ jobs: with: persist-credentials: false - - name: Set up Docker - if: ${{ !github.event.act }} # reconfigures the daemon, which act reaches through a mounted socket - uses: docker/setup-docker-action@77e84dbf09b47d1e29270283c22f16145aa85ca1 # v5.4.0 - with: - version: v29.7.2 # pinned like the rest, this job is the one holding a credential - # containerd store needed to let the build be scanned (provenance and SBOM) and then pushed - daemon-config: | - { - "features": { - "containerd-snapshotter": true - } - } - - - name: Make sure the containerd image store is active # needed to ensure `load: true` because otherwise attestation and provenance are dropped silently - if: ${{ !github.event.act }} # asserts the runner daemon, not whatever a developer happens to run - run: | - docker info --format '{{json .DriverStatus}}' | grep -q 'io.containerd.snapshotter' \ - || (echo "::error::The containerd image store is not active, attestations would be dropped." && exit 1) - - - name: Set up Docker buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Compute the image tags id: docker-meta run: | @@ -325,33 +303,12 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT echo "primary-tag=${PRIMARY}" >> $GITHUB_OUTPUT - - name: Build the image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + - name: Build and scan the image + uses: ./.github/actions/build-image with: - context: . - load: true - push: false tags: ${{ steps.docker-meta.outputs.tags }} - build-args: | - SOURCE_COMMIT=${{ github.sha }} - provenance: mode=max # attach repository info to the image - sbom: true # store attestation manifest inside the image index (including info from application `.cdx.json` files) - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Scan the image - # It will scan the Docker attached SBOM, but also the npm ones copied inside as `.cdx.json` - # "act" cannot support this since trivy action is doing `actions/checkout`, which needs a token act has none of - if: ${{ !github.event.act }} - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - env: - TRIVY_VEX: security/vex.openvex.json # allow bypass some if documented (opt-in file for external scanners, so fine if manipulated) - with: - image-ref: ${{ steps.docker-meta.outputs.primary-tag }} - severity: HIGH,CRITICAL - ignore-unfixed: true - exit-code: '1' - format: table + primary-tag: ${{ steps.docker-meta.outputs.primary-tag }} + source-commit: ${{ github.sha }} - name: Log in to the container registry if: ${{ !github.event.act }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index ea34ad7..0333ad9 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -4,25 +4,144 @@ name: Preview environment # itself is not created here: an Argo CD ApplicationSet in the deployment repository # (deploy/argocd/applicationset-preview.example.yaml) lists the labelled pull requests # and renders deploy/helmfile/preview at each one's own commit, then removes it when the -# pull request closes or loses the label. This repository only has to say where the -# environment is, and nudge Argo CD to look now rather than at its next poll. +# pull request closes or loses the label. This repository's part is the image of the +# pull request's own code, a comment saying where the environment is, and a nudge so Argo +# CD looks now rather than at its next poll. # -# The product images the environment runs are set in the deployment repository, next to -# the ApplicationSet (`pr-` tags of a labelled upstream pull request, or a build -# pushed by hand, see deploy/helm/suite-stack/README.md, "Images"). The encryption image -# is the helmfile's default, the `main` tag: the CI publishes no image for a pull request. +# The image goes where the releases go, `lasuite/encryption` on Docker Hub, so there is +# one registry to know about. Tags: `pr-`, moving with the pull request (what the +# preview helmfile derives from the pull request number by itself), `sha-<8 chars>` for +# the exact commit (for an ApplicationSet that wants a rollout on every push), and the +# branch name for a manual run ("Run workflow" on a `feat/` branch, for a build to +# point a stack at without a pull request: the prefix says the build is a feature in +# progress, and any other branch is refused with a message). Releases (`vX.Y.Z`, +# `latest`) and `main` are never touched: +# the repository's immutable-tag rule protects the versions, and this workflow only writes +# the three shapes above. It also cleans after itself: a pull request's tag goes when it +# closes or loses the label, everything else of those shapes after thirty days. +# +# The push and the cleanup use the Docker Hub token of the `release` environment, the one +# the CI releases with. Its deployment branch rules must admit these runs: a pull request +# runs on GitHub's merge ref, admitted by a branch rule `refs/pull/*/merge`, and a manual +# run on its `feat/*` branch. A token of its own, limited to read, write and +# delete on the repository, in an environment of its own, would keep the release +# credential with the release job alone: the two `environment:` lines below are all that +# changes then. +# +# The product images the environment runs are set in the helmfile's `images` defaults +# (`pr-` tags of a labelled upstream pull request, or a build pushed by hand, see +# deploy/helm/suite-stack/README.md, "Images"). on: pull_request: - types: [labeled, opened, synchronize] + types: [labeled, unlabeled, opened, synchronize, closed] + workflow_dispatch: + schedule: + - cron: '17 4 * * 1' # Monday 04:17 UTC, the thirty-day purge permissions: contents: read +concurrency: + # A new push supersedes the build in progress of the same pull request or branch + group: preview-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CONTAINER_REGISTRY: docker.io + CONTAINER_IMAGE: lasuite/encryption + jobs: + image: + name: Publish the image of this code + # A labelled pull request of this repository (a fork's run gets no secret to push with), + # or a manual run on a feature branch + if: >- + (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/feat/')) || + (github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.action != 'unlabeled' && + contains(github.event.pull_request.labels.*.name, 'preview') && + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: release + permissions: + contents: read + outputs: + tag: ${{ steps.docker-meta.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + # The pull request's own commit, the one the ApplicationSet deploys, not the merge + # commit GitHub makes for the event; a manual run is on the branch head already + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Compute the image tags + id: docker-meta + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BRANCH: ${{ github.ref_name }} + run: | + PRIMARY="${CONTAINER_IMAGE}:sha-${HEAD_SHA:0:8}" + if [[ -n "${PR_NUMBER}" ]]; then + MOVING="pr-${PR_NUMBER}" + else + # A branch name as a tag: lowercase, nothing outside [a-z0-9._-], and never one + # of the shapes the CI owns (a branch called `main`, `latest` or `v1.2.3`) + MOVING="$(echo "${BRANCH}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g' | cut -c1-120)" + if [[ "${MOVING}" == "main" || "${MOVING}" == "latest" || "${MOVING}" =~ ^v[0-9] ]]; then + MOVING="branch-${MOVING}" + fi + fi + echo "tags=${PRIMARY},${CONTAINER_IMAGE}:${MOVING}" >> "$GITHUB_OUTPUT" + echo "primary-tag=${PRIMARY}" >> "$GITHUB_OUTPUT" + echo "tag=sha-${HEAD_SHA:0:8}" >> "$GITHUB_OUTPUT" + echo "moving-tag=${MOVING}" >> "$GITHUB_OUTPUT" + + - name: Build and scan the image # the same steps as a release, see .github/actions/build-image + uses: ./.github/actions/build-image + with: + tags: ${{ steps.docker-meta.outputs.tags }} + primary-tag: ${{ steps.docker-meta.outputs.primary-tag }} + source-commit: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Log in to the container registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ${{ env.CONTAINER_REGISTRY }} + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: Push the scanned image + env: + TAGS: ${{ steps.docker-meta.outputs.tags }} + TAG: ${{ steps.docker-meta.outputs.tag }} + MOVING_TAG: ${{ steps.docker-meta.outputs.moving-tag }} + run: | + IFS=',' read -ra TAG_LIST <<< "${TAGS}" + for tag in "${TAG_LIST[@]}"; do + docker push "${tag}" + done + # The tags only: the repository name would be masked as the registry user's value + echo "Pushed the tags \`${TAG}\` and \`${MOVING_TAG}\`" >> "$GITHUB_STEP_SUMMARY" + + refused: + name: Refuse a manual run outside feat/ + # A skipped job says nothing; this one makes the run red with the reason + if: github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/heads/feat/') + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - run: | + echo "::error::A manual run publishes a feature branch only, named feat/: '${GITHUB_REF_NAME}' is not one. Open a pull request with the preview label instead." + exit 1 + comment: name: Say where it is - if: contains(github.event.pull_request.labels.*.name, 'preview') + needs: [image] + if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -32,18 +151,24 @@ jobs: with: comment-tag: preview-url message: | - :rocket: Preview of this pull request, full stack: + :rocket: **Preview of this pull request, full stack** + - Docs: https://${{ github.event.pull_request.number }}-docs.${{ vars.PREVIEW_DOMAIN }}/ - Drive: https://${{ github.event.pull_request.number }}-drive.${{ vars.PREVIEW_DOMAIN }}/ - Mailbox: https://${{ github.event.pull_request.number }}-mail.${{ vars.PREVIEW_DOMAIN }}/ - Accounts: `alice.martin` / `alice.martin`, `bob.dupont` / `bob.dupont` (see `bootstrap.keycloak.users`), or create your own on the sign-in page (Register). - Everything in this environment is public: put nothing real in it. - The environment is destroyed when this pull request is closed or loses the `preview` label. + **Accounts**: `alice.martin` / `alice.martin`, `bob.dupont` / `bob.dupont` (see `bootstrap.keycloak.users`), or register your own on the sign-in page. + + **Image**: `${{ env.CONTAINER_IMAGE }}:${{ needs.image.outputs.tag }}`, also tagged `pr-${{ github.event.pull_request.number }}`, which the environment runs. Every push republishes it; restart the `stack-encryption` Deployment in Argo CD to pick it up, unless the ApplicationSet follows the commit tag. + + Everything in this environment is public: put nothing real in it. It is destroyed when this pull request is closed or loses the `preview` label. notify-argocd: name: Notify Argo CD - if: contains(github.event.pull_request.labels.*.name, 'preview') && vars.ARGOCD_PREPROD_WEBHOOK_URL != '' + needs: [image] + # After the image exists, so the sync it triggers finds it. Skipped until the + # deployment repository side exists: the variable is set with the secrets when it does. + if: github.event_name == 'pull_request' && vars.ARGOCD_PREPROD_WEBHOOK_URL != '' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -52,3 +177,61 @@ jobs: deployment_repo_path: ${{ secrets.DEPLOYMENT_REPO_URL }} argocd_webhook_secret: ${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }} argocd_url: ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }} + + clean: + name: Clean the registry + # The pull request's tag when it closes or loses the label (the environment goes with + # it), and once a week every `pr-`, `sha-` or branch tag older than thirty days whose + # pull request is not open and labelled any more. Docker Hub has no expiry of its own, + # and its immutable-tag rule on the repository refuses the deletion of a version anyway. + if: >- + github.event_name == 'schedule' || + (github.event_name == 'pull_request' && github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview')) || + (github.event_name == 'pull_request' && github.event.action == 'unlabeled' && github.event.label.name == 'preview') + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release + permissions: + contents: read + pull-requests: read # the open labelled pull requests, whose tags the purge keeps + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - name: Delete the tags + env: + DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }} + DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} + run: | + TOKEN="$(curl -sSf -X POST https://hub.docker.com/v2/users/login/ -H 'Content-Type: application/json' \ + -d "$(jq -n --arg u "${DOCKER_HUB_USER}" --arg p "${DOCKER_HUB_PASSWORD}" '{username: $u, password: $p}')" | jq -r .token)" + delete_tag() { + echo "Deleting ${CONTAINER_IMAGE}:$1" + curl -sSf -X DELETE -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/${CONTAINER_IMAGE}/tags/$1/" + } + + if [[ -n "${PR_NUMBER}" ]]; then + delete_tag "pr-${PR_NUMBER}" + exit 0 + fi + + # The weekly purge. Tags this workflow writes (`pr-`, `sha-<8>`, a branch name, + # that is anything but `main`, `latest` and versions), last pushed more than thirty + # days ago, except the `pr-` of a pull request still open and labelled. + KEEP="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ + --jq '.[] | select(any(.labels[]; .name == "preview")) | "pr-\(.number)"' | paste -sd, - || true)" + CUTOFF="$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" + URL="https://hub.docker.com/v2/repositories/${CONTAINER_IMAGE}/tags/?page_size=100" + while [[ -n "${URL}" && "${URL}" != "null" ]]; do + PAGE="$(curl -sSf -H "Authorization: JWT ${TOKEN}" "${URL}")" + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + delete_tag "${tag}" + done < <(echo "${PAGE}" | jq -r --arg cutoff "${CUTOFF}" --arg keep ",${KEEP}," ' + .results[] + | select(.name != "main" and .name != "latest" and (.name | test("^v[0-9]") | not)) + | select(.last_updated < $cutoff) + | .name as $name | select(($keep | index("," + $name + ",")) == null) + | $name') + URL="$(echo "${PAGE}" | jq -r .next)" + done diff --git a/CLAUDE.md b/CLAUDE.md index df0e054..8f724c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ Single `package.json`, no workspaces. Source in `src/` with clear module separat - `src/demo/` — fake product pages for testing (two instances on different ports) - `src/i18n/` — French translations, i18next setup - `src/build/` — build-time helpers (browser versions from browserslist) -- `deploy/` — deployment material: PostgreSQL role scripts, the Helm chart and its tests (`deploy/helm/encryption.test.ts` renders it with the real `helm` binary and checks the Deployment against `src/server/env-schema.ts`), helmfile and Argo CD examples. The chart is versioned apart from the application: `chart/vX.Y.Z` tags publish `lasuite/encryption-chart` on Docker Hub and run only the chart jobs; `vX.Y.Z` tags release the image and skip the chart. A push to the CI branch republishes the image as `main` only when something outside `deploy/helm` changed, and the chart as `0.0.0-main` only when `deploy/helm/encryption` or `.github` changed, both compared with the commit of the last successful run on the branch (the `changes` job), so a failed release is published again by the next push. `image.tag` is therefore required in the values. `deploy/helm/suite-stack` is a second, unpublished chart for beta testing: the encryption chart (from the checkout) plus the published Docs, Drive and `dev-backend` (PostgreSQL, Redis, MinIO, Keycloak) charts as dependencies, a bootstrap Job that creates the databases, roles, buckets, OIDC clients and tester accounts and runs the encryption migration, Mailpit, and render-time checks that the blocks agree (`templates/_checks.tpl`); `deploy/helm/suite-stack.test.ts` covers it and needs the network for `helm dependency update`. `deploy/helmfile/preview` renders that stack as one environment per pull request (`feature` + `domain` inputs, hostnames derived, namespace `preview-`), deployed per pull request by an Argo CD ApplicationSet in the deployment repository (`deploy/argocd/applicationset-preview.example.yaml`, a pull request generator on the `preview` label); `.github/workflows/preview.yml` only comments the URLs and nudges Argo CD's webhook. +- `deploy/` — deployment material: PostgreSQL role scripts, the Helm chart and its tests (`deploy/helm/encryption.test.ts` renders it with the real `helm` binary and checks the Deployment against `src/server/env-schema.ts`), helmfile and Argo CD examples. The chart is versioned apart from the application: `chart/vX.Y.Z` tags publish `lasuite/encryption-chart` on Docker Hub and run only the chart jobs; `vX.Y.Z` tags release the image and skip the chart. A push to the CI branch republishes the image as `main` only when something outside `deploy/helm` changed, and the chart as `0.0.0-main` only when `deploy/helm/encryption` or `.github` changed, both compared with the commit of the last successful run on the branch (the `changes` job), so a failed release is published again by the next push. `image.tag` is therefore required in the values. `deploy/helm/suite-stack` is a second, unpublished chart for beta testing: the encryption chart (from the checkout) plus the published Docs, Drive and `dev-backend` (PostgreSQL, Redis, MinIO, Keycloak) charts as dependencies, a bootstrap Job that creates the databases, roles, buckets, OIDC clients and tester accounts and runs the encryption migration, Mailpit, and render-time checks that the blocks agree (`templates/_checks.tpl`); `deploy/helm/suite-stack.test.ts` covers it and needs the network for `helm dependency update`. `deploy/helmfile/preview` renders that stack as one environment per pull request (`feature` + `domain` inputs, hostnames derived, namespace `preview-`), deployed per pull request by an Argo CD ApplicationSet in the deployment repository (`deploy/argocd/applicationset-preview.example.yaml`, a pull request generator on the `preview` label); `.github/workflows/preview.yml` publishes the pull request's own image to `lasuite/encryption` on Docker Hub (`pr-`, which the helmfile derives from the number, and `sha-<8>`; the branch name on a manual run, allowed on `feat/*` branches only), with the Docker Hub token of the `release` environment (whose branch rules must admit `refs/pull/*/merge` and `feat/*`), comments the URLs, nudges Argo CD's webhook, and cleans its tags (a pull request's on close or unlabel, the rest after thirty days). The image build and scan steps are shared with the release job through `.github/actions/build-image`. - `.storybook/` — Storybook config with factory pattern from assistant-declaration ## Tech stack diff --git a/deploy/argocd/applicationset-preview.example.yaml b/deploy/argocd/applicationset-preview.example.yaml index 69363af..1dc1bc4 100644 --- a/deploy/argocd/applicationset-preview.example.yaml +++ b/deploy/argocd/applicationset-preview.example.yaml @@ -60,15 +60,24 @@ spec: path: deploy/helmfile/preview plugin: name: helmfile - # The helmfile reads the two inputs every hostname derives from as environment - # variables (FEATURE, DOMAIN, or their ARGOCD_ENV_ form), so any plugin that runs - # `helmfile -e preview template` in the directory renders the right environment. - # The products' image tags stay the helmfile's defaults. + # The helmfile reads the two inputs every hostname derives from, and the + # encryption image, as environment variables (FEATURE, DOMAIN, ENCRYPTION_IMAGE, + # or their ARGOCD_ENV_ form), so any plugin that runs `helmfile -e preview + # template` in the directory renders the right environment. The products' image + # tags stay the helmfile's defaults. env: - name: FEATURE value: '{{ .number }}' - name: DOMAIN value: ppr.example.org + # Optional. Without it the helmfile runs `lasuite/encryption:pr-`, the + # moving tag .github/workflows/preview.yml republishes on every push, which the + # pods only pick up when restarted. With it, the tag of this very commit: the + # manifest changes on every push and Argo CD rolls the pods itself. It may sync + # before the workflow has pushed the image; the pods then fail to pull for a + # few minutes and start on their own once it is there. + - name: ENCRYPTION_IMAGE + value: 'lasuite/encryption:sha-{{ .head_short_sha }}' # The two below are the argo-cd-helmfile plugin's own: the environment to # render, and the release namespace following the destination. Another plugin # selects the environment its own way (an APP_ENV variable, for instance). diff --git a/deploy/helm/suite-stack.test.ts b/deploy/helm/suite-stack.test.ts index b53a10f..4d7d2cd 100644 --- a/deploy/helm/suite-stack.test.ts +++ b/deploy/helm/suite-stack.test.ts @@ -502,9 +502,19 @@ describe('the stack chart', () => { expect(fromEnvironment).toContain('namespace: "preview-7"'); expect(fromEnvironment).not.toMatch(/ci-[a-z]+\.example\.org/); - const prefixed = run('helmfile', args, undefined, { ARGOCD_ENV_FEATURE: '8', ARGOCD_ENV_DOMAIN: 'ppr.example.net' }); + // A number is a pull request: its own image, the tag the preview workflow publishes + expect(fromEnvironment).toContain('image: "lasuite/encryption:pr-7"'); + expect(run('helmfile', args, undefined, { FEATURE: 'beta', DOMAIN: 'ppr.example.net' })).toContain('image: "lasuite/encryption:main"'); + + const prefixed = run('helmfile', args, undefined, { + ARGOCD_ENV_FEATURE: '8', + ARGOCD_ENV_DOMAIN: 'ppr.example.net', + ARGOCD_ENV_ENCRYPTION_IMAGE: 'lasuite/encryption:sha-0123abcd', + }); expect(prefixed).toContain('8-encryption.ppr.example.net'); + expect(prefixed).toContain('image: "lasuite/encryption:sha-0123abcd"'); + expect(() => run('helmfile', args, undefined, { ENCRYPTION_IMAGE: 'no-tag' })).toThrow(/ENCRYPTION_IMAGE must be repository:tag/); const overridden = run('helmfile', [...args.slice(0, -1), '--state-values-set', 'feature=9', 'template'], undefined, { FEATURE: '7', diff --git a/deploy/helm/suite-stack/README.md b/deploy/helm/suite-stack/README.md index b3f82da..b769d45 100644 --- a/deploy/helm/suite-stack/README.md +++ b/deploy/helm/suite-stack/README.md @@ -43,7 +43,7 @@ The four subcharts each read their own block, so a hostname or a client id is wr ## Images -Whichever way the product images are produced, the stack only ever sees a reference: `ci/staging-values.yaml` (or the `images` inputs of the preview helmfile) is where it is set, and the coherence checks do not look at it. Two ways exist: +Whichever way the product images are produced, the stack only ever sees a reference: `ci/staging-values.yaml` (or the `images` inputs of the preview helmfile) is where it is set, and the coherence checks do not look at it. The encryption image of a pull request is published by this repository's own preview workflow (see "One environment per pull request"); for Docs and Drive, two ways exist: 1. **The products' own preview publishing.** A pull request in the upstream Docs repository carrying the `preview` label gets its images pushed by the Docs CI as `lasuite/impress-backend:pr-`, `impress-frontend:pr-` and `impress-y-provider:pr-`, republished on every push to that pull request. This is the path with no manual step: open the integration branches as upstream pull requests, label them, and point the stack at the `pr-` tags. Drive does not push images for pull requests today; once it does, the same applies to it. 2. **A build by hand**, pushed to a registry account of your own (the targets are those of each repository's `docker-hub.yml`): @@ -56,7 +56,7 @@ Whichever way the product images are produced, the stack only ever sees a refere # Drive: backend, frontend docker build --target backend-production -t myaccount/drive-backend:e2ee . docker build --target frontend-production -t myaccount/drive-frontend:e2ee -f src/frontend/Dockerfile . - # The encryption service (this repository), or use the `main` tag the CI publishes + # The encryption service (this repository): the preview workflow publishes it, see below docker build -t myaccount/encryption:e2ee . docker push ... ``` @@ -95,7 +95,7 @@ The notes printed at the end of either path list every URL and account. Keycloak ## One environment per pull request -The same helmfile with `feature` set to the pull request number is the preview environment: the chart is read from the checkout, so a pull request that changes it is previewed with its own chart, and nothing is published for it. The machinery lives in the deployment repository, not here: `deploy/argocd/applicationset-preview.example.yaml` is an ApplicationSet whose pull request generator lists the pull requests of this repository carrying the `preview` label and renders the helmfile at each one's commit through a helmfile plugin; when the pull request closes or loses the label, the Application goes and prunes its namespace. The inputs travel as environment variables of the Application, `FEATURE` (the number) and `DOMAIN`, because that is what a plugin can pass to the command it runs: the helmfile reads them (also under the `ARGOCD_ENV_` prefix a sidecar plugin adds) when no state value is given. An Ingress carrying `ci-docs.example.org`, the defaults, means the variables did not reach the render. This repository's part is `.github/workflows/preview.yml`: a comment with the URLs, and a webhook nudge so Argo CD re-evaluates now rather than at its next poll. +The same helmfile with `feature` set to the pull request number is the preview environment: the chart is read from the checkout, so a pull request that changes it is previewed with its own chart, and nothing is published for it. The machinery lives in the deployment repository, not here: `deploy/argocd/applicationset-preview.example.yaml` is an ApplicationSet whose pull request generator lists the pull requests of this repository carrying the `preview` label and renders the helmfile at each one's commit through a helmfile plugin; when the pull request closes or loses the label, the Application goes and prunes its namespace. The inputs travel as environment variables of the Application, `FEATURE` (the number) and `DOMAIN`, because that is what a plugin can pass to the command it runs: the helmfile reads them (also under the `ARGOCD_ENV_` prefix a sidecar plugin adds) when no state value is given. An Ingress carrying `ci-docs.example.org`, the defaults, means the variables did not reach the render. This repository's part is `.github/workflows/preview.yml`: the encryption image of the pull request's own commit, pushed to Docker Hub as `lasuite/encryption:pr-` on every push, which the helmfile derives from the number by itself (and `sha-<8 chars>`, for an ApplicationSet that passes `ENCRYPTION_IMAGE` to get a rollout on every push rather than a restart); a comment with the URLs; and a webhook nudge so Argo CD re-evaluates now rather than at its next poll. The same workflow run by hand ("Run workflow" on a `feat/` branch, the prefix saying the build is a feature in progress; any other branch is refused with a message) publishes that branch under its name, `feat-`, for a stack to point at without a pull request. Releases and `main` are the CI's alone, and the workflow cleans after itself: a pull request's tag goes when it closes or loses the label, the other preview tags after thirty days. It pushes and deletes with the Docker Hub token of the `release` environment, the CI's, whose deployment branch rules must admit a pull request's merge ref (`refs/pull/*/merge`) and the feature branches (`feat/*`); a token of its own in an environment of its own would keep the release credential with the release job alone, and is a two-line change when one exists. ## Adding another product diff --git a/deploy/helmfile/preview/helmfile.yaml.gotmpl b/deploy/helmfile/preview/helmfile.yaml.gotmpl index d2715a9..b9abd4c 100644 --- a/deploy/helmfile/preview/helmfile.yaml.gotmpl +++ b/deploy/helmfile/preview/helmfile.yaml.gotmpl @@ -18,6 +18,13 @@ # command-line options, and a sidecar plugin sees them prefixed ARGOCD_ENV_. A state # value wins over the variable, the variable over the default below. # +# The encryption image follows: a numeric feature is a pull request number, and the +# preview workflow publishes that pull request's code as `lasuite/encryption:pr-` +# on every push, so that tag is the default; any other feature (`beta`, `ci`) runs the +# `main` tag of the CI branch. A third variable, ENCRYPTION_IMAGE (`repository:tag`), +# overrides both: an ApplicationSet that passes the commit tag the workflow also +# publishes, `sha-<8 chars>`, gets a rollout on every push instead of a moving tag. +# # Hostnames come out as -docs., -drive., # -encryption. and data.-encryption. (the vault must # start with `data.`), -auth, -s3, -s3-console, -mail @@ -26,19 +33,25 @@ # destination), else `preview-`. {{- $feature := coalesce (env "ARGOCD_ENV_FEATURE") (env "FEATURE") "ci" }} {{- $domain := coalesce (env "ARGOCD_ENV_DOMAIN") (env "DOMAIN") "example.org" }} +{{- $defaultTag := ternary (printf "pr-%s" $feature) "main" (regexMatch "^[0-9]+$" $feature) }} +{{- $image := coalesce (env "ARGOCD_ENV_ENCRYPTION_IMAGE") (env "ENCRYPTION_IMAGE") (printf "lasuite/encryption:%s" $defaultTag) }} +{{- $imageParts := splitList ":" $image }} +{{- if lt (len $imageParts) 2 }}{{ fail (printf "ENCRYPTION_IMAGE must be repository:tag, got %q" $image) }}{{ end }} +{{- $imageTag := last $imageParts }} +{{- $imageRepository := join ":" (initial $imageParts) }} environments: preview: values: - feature: {{ $feature | quote }} domain: {{ $domain | quote }} - # The images of the branches under test. The encryption `main` tag follows the CI - # branch; the products' tags are either the `pr-` ones their CI pushes for - # a labelled upstream pull request, or a build pushed by hand (see the stack - # README, "Images"). Either way the reference is set here, on our side. + # The images of the branches under test. The encryption one is derived above; + # the products' tags are either the `pr-` ones their CI pushes for a + # labelled upstream pull request, or a build pushed by hand (see the stack README, + # "Images"). Either way the reference is set here, on our side. images: encryption: - repository: lasuite/encryption - tag: main + repository: {{ $imageRepository | quote }} + tag: {{ $imageTag | quote }} docs: repository: lasuite/impress-backend tag: main From 5c46fc358fe54d6aa908ef23ca7b77fa08ff1775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 23 Sep 2026 13:16:38 +0200 Subject: [PATCH 09/47] chore(deploy): allow setting other product image tags through labels for previews --- .github/workflows/preview.yml | 40 +++++++++++++++++-- .../applicationset-preview.example.yaml | 10 +++++ deploy/helm/suite-stack.test.ts | 11 +++++ deploy/helm/suite-stack/README.md | 4 +- deploy/helmfile/preview/helmfile.yaml.gotmpl | 18 ++++++--- 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 0333ad9..bca2594 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -28,9 +28,12 @@ name: Preview environment # credential with the release job alone: the two `environment:` lines below are all that # changes then. # -# The product images the environment runs are set in the helmfile's `images` defaults -# (`pr-` tags of a labelled upstream pull request, or a build pushed by hand, see -# deploy/helm/suite-stack/README.md, "Images"). +# The product images the environment runs come from labels of the pull request, +# `docs:` and `drive:` (`pr-` tags of a labelled upstream pull request, +# or a build pushed by hand, see deploy/helm/suite-stack/README.md, "Images"), which the +# ApplicationSet passes to the helmfile; without one, `main`. A label is an object of the +# repository, not of the pull request, so this workflow deletes a product label once no +# open pull request carries it any more: the list of labels never accumulates versions. on: pull_request: @@ -178,6 +181,37 @@ jobs: argocd_webhook_secret: ${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }} argocd_url: ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }} + labels: + name: Delete the product labels nobody uses + # `docs:` and `drive:` are created for one pull request and would outlive it + # in the repository's label list: gone when the last open pull request carrying one + # closes or drops it. + if: >- + (github.event_name == 'pull_request' && github.event.action == 'closed') || + (github.event_name == 'pull_request' && github.event.action == 'unlabeled' && (startsWith(github.event.label.name, 'docs:') || startsWith(github.event.label.name, 'drive:'))) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write # labels are issue objects + pull-requests: read + env: + GH_TOKEN: ${{ github.token }} + # On a close, every product label of the pull request; on an unlabel, the one removed + CANDIDATES: ${{ github.event.action == 'unlabeled' && github.event.label.name || join(github.event.pull_request.labels.*.name, ',') }} + steps: + - run: | + IN_USE="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" --jq '.[].labels[].name' || true)" + IFS=',' read -ra LABELS <<< "${CANDIDATES}" + for label in "${LABELS[@]}"; do + [[ "${label}" == docs:* || "${label}" == drive:* ]] || continue + if grep -qxF "${label}" <<< "${IN_USE}"; then + echo "Keeping ${label}, an open pull request carries it" + continue + fi + echo "Deleting the label ${label}" + gh label delete "${label}" --yes + done + clean: name: Clean the registry # The pull request's tag when it closes or loses the label (the environment goes with diff --git a/deploy/argocd/applicationset-preview.example.yaml b/deploy/argocd/applicationset-preview.example.yaml index 1dc1bc4..4b3cfdb 100644 --- a/deploy/argocd/applicationset-preview.example.yaml +++ b/deploy/argocd/applicationset-preview.example.yaml @@ -78,6 +78,16 @@ spec: # few minutes and start on their own once it is there. - name: ENCRYPTION_IMAGE value: 'lasuite/encryption:sha-{{ .head_short_sha }}' + # The Docs and Drive images under test, from labels of the pull request: + # `docs:pr-2694` runs the images the Docs CI pushed for its labelled pull + # request 2694, `drive:encryption` the ones pushed by hand for that branch + # (see deploy/helm/suite-stack/README.md, "Images"). No label, `main`; several + # for one product, the first. The helmfile's committed defaults never carry a + # test version this way, so there is nothing to undo before merging. + - name: DOCS_TAG + value: '{{ $t := list }}{{ range .labels }}{{ if hasPrefix "docs:" . }}{{ $t = append $t (trimPrefix "docs:" .) }}{{ end }}{{ end }}{{ with first $t }}{{ . }}{{ end }}' + - name: DRIVE_TAG + value: '{{ $t := list }}{{ range .labels }}{{ if hasPrefix "drive:" . }}{{ $t = append $t (trimPrefix "drive:" .) }}{{ end }}{{ end }}{{ with first $t }}{{ . }}{{ end }}' # The two below are the argo-cd-helmfile plugin's own: the environment to # render, and the release namespace following the destination. Another plugin # selects the environment its own way (an APP_ENV variable, for instance). diff --git a/deploy/helm/suite-stack.test.ts b/deploy/helm/suite-stack.test.ts index 4d7d2cd..8e1304b 100644 --- a/deploy/helm/suite-stack.test.ts +++ b/deploy/helm/suite-stack.test.ts @@ -514,6 +514,17 @@ describe('the stack chart', () => { expect(prefixed).toContain('8-encryption.ppr.example.net'); expect(prefixed).toContain('image: "lasuite/encryption:sha-0123abcd"'); + expect(prefixed).toContain('image: "lasuite/impress-frontend:main"'); + + // The products under test, from labels of the pull request: one tag per product + const products = run('helmfile', args, undefined, { FEATURE: '7', DOMAIN: 'ppr.example.net', DOCS_TAG: 'pr-2694', DRIVE_TAG: 'encryption' }); + + for (const image of ['impress-backend', 'impress-frontend', 'impress-y-provider']) { + expect(products).toContain(`image: "lasuite/${image}:pr-2694"`); + } + for (const image of ['drive-backend', 'drive-frontend']) { + expect(products).toContain(`image: "lasuite/${image}:encryption"`); + } expect(() => run('helmfile', args, undefined, { ENCRYPTION_IMAGE: 'no-tag' })).toThrow(/ENCRYPTION_IMAGE must be repository:tag/); const overridden = run('helmfile', [...args.slice(0, -1), '--state-values-set', 'feature=9', 'template'], undefined, { diff --git a/deploy/helm/suite-stack/README.md b/deploy/helm/suite-stack/README.md index b769d45..2c71df8 100644 --- a/deploy/helm/suite-stack/README.md +++ b/deploy/helm/suite-stack/README.md @@ -45,7 +45,7 @@ The four subcharts each read their own block, so a hostname or a client id is wr Whichever way the product images are produced, the stack only ever sees a reference: `ci/staging-values.yaml` (or the `images` inputs of the preview helmfile) is where it is set, and the coherence checks do not look at it. The encryption image of a pull request is published by this repository's own preview workflow (see "One environment per pull request"); for Docs and Drive, two ways exist: -1. **The products' own preview publishing.** A pull request in the upstream Docs repository carrying the `preview` label gets its images pushed by the Docs CI as `lasuite/impress-backend:pr-`, `impress-frontend:pr-` and `impress-y-provider:pr-`, republished on every push to that pull request. This is the path with no manual step: open the integration branches as upstream pull requests, label them, and point the stack at the `pr-` tags. Drive does not push images for pull requests today; once it does, the same applies to it. +1. **The products' own preview publishing.** A pull request in the upstream Docs repository carrying the `preview` label gets its images pushed by the Docs CI as `lasuite/impress-backend:pr-`, `impress-frontend:pr-` and `impress-y-provider:pr-`, republished on every push to that pull request. This is the path with no manual step: open the integration branches as upstream pull requests, label them, and point the stack at the `pr-` tags (for a preview, with a `docs:pr-` label on the encryption pull request, see below). Drive does not push images for pull requests today; once it does, the same applies to it. 2. **A build by hand**, pushed to a registry account of your own (the targets are those of each repository's `docker-hub.yml`): ```bash @@ -95,7 +95,7 @@ The notes printed at the end of either path list every URL and account. Keycloak ## One environment per pull request -The same helmfile with `feature` set to the pull request number is the preview environment: the chart is read from the checkout, so a pull request that changes it is previewed with its own chart, and nothing is published for it. The machinery lives in the deployment repository, not here: `deploy/argocd/applicationset-preview.example.yaml` is an ApplicationSet whose pull request generator lists the pull requests of this repository carrying the `preview` label and renders the helmfile at each one's commit through a helmfile plugin; when the pull request closes or loses the label, the Application goes and prunes its namespace. The inputs travel as environment variables of the Application, `FEATURE` (the number) and `DOMAIN`, because that is what a plugin can pass to the command it runs: the helmfile reads them (also under the `ARGOCD_ENV_` prefix a sidecar plugin adds) when no state value is given. An Ingress carrying `ci-docs.example.org`, the defaults, means the variables did not reach the render. This repository's part is `.github/workflows/preview.yml`: the encryption image of the pull request's own commit, pushed to Docker Hub as `lasuite/encryption:pr-` on every push, which the helmfile derives from the number by itself (and `sha-<8 chars>`, for an ApplicationSet that passes `ENCRYPTION_IMAGE` to get a rollout on every push rather than a restart); a comment with the URLs; and a webhook nudge so Argo CD re-evaluates now rather than at its next poll. The same workflow run by hand ("Run workflow" on a `feat/` branch, the prefix saying the build is a feature in progress; any other branch is refused with a message) publishes that branch under its name, `feat-`, for a stack to point at without a pull request. Releases and `main` are the CI's alone, and the workflow cleans after itself: a pull request's tag goes when it closes or loses the label, the other preview tags after thirty days. It pushes and deletes with the Docker Hub token of the `release` environment, the CI's, whose deployment branch rules must admit a pull request's merge ref (`refs/pull/*/merge`) and the feature branches (`feat/*`); a token of its own in an environment of its own would keep the release credential with the release job alone, and is a two-line change when one exists. +The same helmfile with `feature` set to the pull request number is the preview environment: the chart is read from the checkout, so a pull request that changes it is previewed with its own chart, and nothing is published for it. The machinery lives in the deployment repository, not here: `deploy/argocd/applicationset-preview.example.yaml` is an ApplicationSet whose pull request generator lists the pull requests of this repository carrying the `preview` label and renders the helmfile at each one's commit through a helmfile plugin; when the pull request closes or loses the label, the Application goes and prunes its namespace. The inputs travel as environment variables of the Application, `FEATURE` (the number) and `DOMAIN`, because that is what a plugin can pass to the command it runs: the helmfile reads them (also under the `ARGOCD_ENV_` prefix a sidecar plugin adds) when no state value is given. Two more, `DOCS_TAG` and `DRIVE_TAG`, name the product images under test, one tag per product for all its images, and the ApplicationSet reads them from labels of the pull request, `docs:pr-2694` or `drive:encryption`: the committed defaults stay `main`, the test versions live on the pull request, and there is nothing to undo before merging. An Ingress carrying `ci-docs.example.org`, the defaults, means the variables did not reach the render. This repository's part is `.github/workflows/preview.yml`: the encryption image of the pull request's own commit, pushed to Docker Hub as `lasuite/encryption:pr-` on every push, which the helmfile derives from the number by itself (and `sha-<8 chars>`, for an ApplicationSet that passes `ENCRYPTION_IMAGE` to get a rollout on every push rather than a restart); a comment with the URLs; and a webhook nudge so Argo CD re-evaluates now rather than at its next poll. The same workflow run by hand ("Run workflow" on a `feat/` branch, the prefix saying the build is a feature in progress; any other branch is refused with a message) publishes that branch under its name, `feat-`, for a stack to point at without a pull request. Releases and `main` are the CI's alone, and the workflow cleans after itself: a pull request's tag goes when it closes or loses the label, the other preview tags after thirty days. It pushes and deletes with the Docker Hub token of the `release` environment, the CI's, whose deployment branch rules must admit a pull request's merge ref (`refs/pull/*/merge`) and the feature branches (`feat/*`); a token of its own in an environment of its own would keep the release credential with the release job alone, and is a two-line change when one exists. ## Adding another product diff --git a/deploy/helmfile/preview/helmfile.yaml.gotmpl b/deploy/helmfile/preview/helmfile.yaml.gotmpl index b9abd4c..201ad9f 100644 --- a/deploy/helmfile/preview/helmfile.yaml.gotmpl +++ b/deploy/helmfile/preview/helmfile.yaml.gotmpl @@ -25,6 +25,12 @@ # overrides both: an ApplicationSet that passes the commit tag the workflow also # publishes, `sha-<8 chars>`, gets a rollout on every push instead of a moving tag. # +# The products' tags come from DOCS_TAG and DRIVE_TAG, one per product for all its +# images (Docs' CI tags its three images alike, Drive's two alike), `main` when unset. +# The ApplicationSet reads them from labels of the pull request, `docs:pr-2694` and +# `drive:encryption`, so the committed defaults stay `main` and a pull request tests a +# product branch without a commit to undo before merging. +# # Hostnames come out as -docs., -drive., # -encryption. and data.-encryption. (the vault must # start with `data.`), -auth, -s3, -s3-console, -mail @@ -39,6 +45,8 @@ {{- if lt (len $imageParts) 2 }}{{ fail (printf "ENCRYPTION_IMAGE must be repository:tag, got %q" $image) }}{{ end }} {{- $imageTag := last $imageParts }} {{- $imageRepository := join ":" (initial $imageParts) }} +{{- $docsTag := coalesce (env "ARGOCD_ENV_DOCS_TAG") (env "DOCS_TAG") "main" }} +{{- $driveTag := coalesce (env "ARGOCD_ENV_DRIVE_TAG") (env "DRIVE_TAG") "main" }} environments: preview: values: @@ -54,19 +62,19 @@ environments: tag: {{ $imageTag | quote }} docs: repository: lasuite/impress-backend - tag: main + tag: {{ $docsTag | quote }} docsFrontend: repository: lasuite/impress-frontend - tag: main + tag: {{ $docsTag | quote }} docsYProvider: repository: lasuite/impress-y-provider - tag: main + tag: {{ $docsTag | quote }} drive: repository: lasuite/drive-backend - tag: main + tag: {{ $driveTag | quote }} driveFrontend: repository: lasuite/drive-frontend - tag: main + tag: {{ $driveTag | quote }} ingressClassName: nginx clusterIssuer: letsencrypt --- From 7efce9e8648725dd44657fd93764a8766f2e3a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Thu, 24 Sep 2026 19:57:18 +0200 Subject: [PATCH 10/47] chore(ui): adjust flows and integration + tiny change db schema change --- .env.test | 2 +- .storybook/main.ts | 3 + .storybook/preview.tsx | 90 +- .storybook/testing.tsx | 4 +- CLAUDE.md | 4 +- README.md | 2 +- .../__snapshots__/suite-stack.test.ts.snap | 3 +- .../files/postgres/create-migrator-role.sql | 3 +- deploy/postgres/create-migrator-role.sql | 3 +- package.json | 1 + src/build/css-module-types.test.ts | 24 + src/build/css-module-types.ts | 54 ++ src/client/vault-client.ts | 41 +- src/demo/DemoApp.tsx | 54 +- src/demo/ModalNoKey.tsx | 5 +- src/demo/ShareDocumentModal.tsx | 56 +- src/demo/index.html | 31 + src/i18n/en/common.json | 160 +-- src/i18n/fr/common.json | 160 +-- src/prisma/client.ts | 16 +- .../20260921153450_baseline/migration.sql | 135 ++- src/prisma/schema.prisma | 23 - src/prisma/testing.ts | 4 +- src/server/email/layout.tsx | 2 +- src/server/email/styles.ts | 9 +- src/server/plugins/security-headers.test.ts | 13 + src/server/plugins/security-headers.ts | 19 +- src/shared/constants.ts | 2 + src/ui/App.tsx | 133 +-- src/ui/assets/illustrations/shield-check.png | Bin 0 -> 45753 bytes src/ui/assets/illustrations/shield-x.png | Bin 0 -> 45565 bytes .../components/DecimalCodeInput.stories.tsx | 4 + src/ui/components/DecimalCodeInput.tsx | 54 +- src/ui/components/DeviceApproval.stories.tsx | 18 +- src/ui/components/DeviceApproval.tsx | 336 +++---- src/ui/components/EmergencyAccess.stories.tsx | 3 +- src/ui/components/EmergencyAccess.tsx | 913 ++++++++---------- .../components/EncryptionSettings.stories.tsx | 51 +- src/ui/components/EncryptionSettings.tsx | 683 ++++++------- .../components/FingerprintDisplay.stories.tsx | 33 - src/ui/components/FingerprintDisplay.tsx | 16 - .../ModalEncryptionOnboarding.stories.tsx | 98 +- .../components/ModalEncryptionOnboarding.tsx | 575 +++++------ .../RecipientFingerprintControls.stories.tsx | 57 -- .../RecipientFingerprintControls.tsx | 62 +- .../components/RecipientProfile.stories.tsx | 25 +- src/ui/components/RecipientProfile.tsx | 193 ++-- .../components/RecoveryKitBackup.stories.tsx | 51 +- src/ui/components/RecoveryKitBackup.tsx | 305 ++---- .../RecoveryPhraseInput.stories.tsx | 25 +- src/ui/components/RecoveryPhraseInput.tsx | 67 +- .../SessionExpiredAlert.stories.tsx | 6 + src/ui/components/SessionExpiredAlert.tsx | 22 +- .../components/VerifyRecipients.stories.tsx | 2 +- src/ui/components/VerifyRecipients.tsx | 198 ++-- .../layout/IdentityCard.stories.tsx | 90 ++ src/ui/components/layout/IdentityCard.tsx | 153 +++ src/ui/components/layout/Screen.stories.tsx | 123 +++ src/ui/components/layout/Screen.tsx | 106 ++ src/ui/components/layout/layout.module.css | 658 +++++++++++++ .../components/layout/layout.module.css.d.ts | 69 ++ src/ui/docs/technical/integration.mdx | 18 +- src/ui/hooks/useCloseRequest.tsx | 69 ++ src/ui/hooks/useParentMessages.ts | 29 +- src/ui/index.tsx | 5 +- src/ui/interface.html | 4 + src/ui/testing/default-handlers.ts | 9 +- src/ui/vite.config.ts | 3 + src/vault/message-handler.ts | 28 +- src/vault/user-resolution.ts | 14 +- src/vault/vault-sync-driver.test.ts | 108 +++ src/vault/vault-sync-driver.ts | 88 +- 72 files changed, 3979 insertions(+), 2448 deletions(-) create mode 100644 src/build/css-module-types.test.ts create mode 100644 src/build/css-module-types.ts create mode 100644 src/ui/assets/illustrations/shield-check.png create mode 100644 src/ui/assets/illustrations/shield-x.png delete mode 100644 src/ui/components/FingerprintDisplay.stories.tsx delete mode 100644 src/ui/components/FingerprintDisplay.tsx delete mode 100644 src/ui/components/RecipientFingerprintControls.stories.tsx create mode 100644 src/ui/components/layout/IdentityCard.stories.tsx create mode 100644 src/ui/components/layout/IdentityCard.tsx create mode 100644 src/ui/components/layout/Screen.stories.tsx create mode 100644 src/ui/components/layout/Screen.tsx create mode 100644 src/ui/components/layout/layout.module.css create mode 100644 src/ui/components/layout/layout.module.css.d.ts create mode 100644 src/ui/hooks/useCloseRequest.tsx create mode 100644 src/vault/vault-sync-driver.test.ts diff --git a/.env.test b/.env.test index 9ca56fc..161f076 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,5 @@ PORT=7200 -DATABASE_URL=postgresql://postgres:changeme@localhost:7205/postgres?schema=encryption +DATABASE_URL=postgresql://postgres:changeme@localhost:7205/postgres VAULT_URL=http://data.encryption.localhost:7200 UI_URL=http://encryption.localhost:7200 ALLOWED_FRAME_ANCESTORS=http://localhost:7201,http://localhost:7202,http://a.product.localhost:7201,http://a.product.localhost:7202,http://b.product.localhost:7201,http://b.product.localhost:7202 diff --git a/.storybook/main.ts b/.storybook/main.ts index ab70b0b..6e406b0 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -56,6 +56,9 @@ const config: StorybookConfig = { config.plugins = [...(config.plugins ?? []), vendorMarianneFonts()]; + // Must mirror src/ui/vite.config.ts: the typed keys of the CSS modules are camelCased. + config.css = { ...(config.css ?? {}), modules: { ...(config.css?.modules ?? {}), localsConvention: 'camelCaseOnly' } }; + return config; }, }; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index a4d8950..cdb9980 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,10 +1,11 @@ -import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; +import { Button, CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; import '@gouvfr-lasuite/cunningham-react/icons'; import '@gouvfr-lasuite/cunningham-react/style'; +import { Icon } from '@gouvfr-lasuite/ui-kit'; import type { Preview } from '@storybook/react'; import { mswLoader } from 'msw-storybook-addon/csf3'; import { setupWorker } from 'msw/browser'; -import { useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import { I18nextProvider } from 'react-i18next'; import { addons } from 'storybook/preview-api'; import { themes } from 'storybook/theming'; @@ -12,6 +13,7 @@ import { themes } from 'storybook/theming'; import { useNavigationGuard } from '@encryption/.storybook/navigation-guard'; import i18n from '@encryption/src/i18n'; import { DEFAULT_LOCALE } from '@encryption/src/shared/locale'; +import { CloseRequestProvider } from '@encryption/src/ui/hooks/useCloseRequest'; import type { EncryptionContextType } from '@encryption/src/ui/providers/EncryptionProvider'; import { defaultHandlers } from '@encryption/src/ui/testing/default-handlers'; import { MockEncryptionProvider } from '@encryption/src/ui/testing/mock-encryption'; @@ -48,12 +50,38 @@ function useStorybookDarkMode(): boolean { return isDark; } +function HostCloseControl({ children }: { children: ReactNode }) { + const [requests, setRequests] = useState(0); + + return ( + <> +
+
+ console.log('host: interface closed')}> + {children} + + + ); +} + const preview: Preview = { - // Start the MSW worker once. Only warn on UNMOCKED /api calls (so a missing - // handler is visible) while staying silent for Storybook's own asset requests. + // Start the MSW worker once, with the 501 safety net as its INITIAL handlers: + // the addon prepends a story's own handlers at runtime, so they win, while the + // net stays behind them for everything the story did not declare. (As a + // `parameters.msw.handlers` array the net would be dropped, not merged, by any + // story declaring its own array.) Only warn on UNMOCKED /api calls, and stay + // silent for Storybook's own asset requests. loaders: [ mswLoader(async () => { - const worker = setupWorker(); + const worker = setupWorker(...defaultHandlers); await worker.start({ quiet: true, @@ -96,9 +124,6 @@ const preview: Preview = { dark: { ...themes.dark }, light: { ...themes.light }, }, - msw: { - handlers: defaultHandlers, - }, }, decorators: [ // Every interface component reads the vault through useEncryptionContext. @@ -124,29 +149,70 @@ const preview: Preview = { const isDark = useStorybookDarkMode(); const cunninghamTheme = isDark ? 'dark' : 'default'; - // Also update the root element so any raw CSS using Cunningham variables works + const hostModal = context.parameters.hostModal as boolean | 'medium' | 'card' | undefined; + + // Also update the root element so any raw CSS using Cunningham variables + // works, and paint the page itself: a centered story covers only its own + // box, and what shows around it is the preview document, which knows + // nothing of the theme otherwise. useEffect(() => { document.documentElement.classList.toggle('cunningham-theme--dark', isDark); document.documentElement.classList.toggle('cunningham-theme--default', !isDark); - }, [isDark]); + document.body.style.backgroundColor = hostModal + ? 'var(--c--contextuals--background--surface--tertiary)' + : 'var(--c--contextuals--background--surface--primary)'; + }, [isDark, hostModal]); // Fullscreen stories (a document PDF preview, an email, a docs page) only drop // the padding to go edge-to-edge; minHeight (not a fixed height) lets taller // content grow and scroll rather than being clipped. const isFullscreen = context.parameters.layout === 'fullscreen'; + // Interface screens are content for a modal the PRODUCT draws around the + // iframe (card, padding, close control). `hostModal` stands in for that + // chrome so a story shows the screen at the width it really gets: the + // design system's small modal (350px) or, for list-heavy screens, the + // medium one. The cross is the product's: it raises the same close request + // the SDK relays, so a screen holding a guard reacts like it does in the + // product, and an unguarded one only logs. `'card'` is the width alone, for + // a component that lives inside a screen and has no close control of its own. return (
- + {hostModal ? ( +
+ {hostModal === 'card' ? ( + + ) : ( + + + + )} +
+ ) : ( + + )}
diff --git a/.storybook/testing.tsx b/.storybook/testing.tsx index 16eba94..d5b5840 100644 --- a/.storybook/testing.tsx +++ b/.storybook/testing.tsx @@ -46,8 +46,8 @@ export async function playFindAlert(parentElement: HTMLElement, text?: string | }, options); } -export async function playFindHeading(parentElement: HTMLElement, name: string | RegExp): Promise { - return await within(parentElement).findByRole('heading', { name }); +export async function playFindHeading(parentElement: HTMLElement, name: string | RegExp, options?: { timeout?: number }): Promise { + return await within(parentElement).findByRole('heading', { name }, options); } export async function playFindDocumentStructure(parentElement: HTMLElement): Promise { diff --git a/CLAUDE.md b/CLAUDE.md index 8f724c7..4536316 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Single `package.json`, no workspaces. Source in `src/` with clear module separat - `src/ui/` — React app (Cunningham, i18next, MDX docs, browser check) - `src/server/` — Fastify server, Host-based routing, API routes, security headers - `src/shared/` — constants, Zod schemas, error codes (shared between server and client) -- `src/prisma/` — Prisma 7 schema, client with `@prisma/adapter-pg`; every model and enum sits in the `encryption` PostgreSQL schema (`@@schema`), never `public`, and `DATABASE_URL` carries `?schema=encryption` for the migration tooling +- `src/prisma/` — Prisma 7 schema, client with `@prisma/adapter-pg`; the PostgreSQL schema comes from the `?schema=` parameter of `DATABASE_URL` (read by both the migration tooling and the runtime adapter): deployments use a dedicated `encryption` schema, never `public` (see README), while the local `.env.test*` files leave it out and use `public` - `src/demo/` — fake product pages for testing (two instances on different ports) - `src/i18n/` — French translations, i18next setup - `src/build/` — build-time helpers (browser versions from browserslist) @@ -83,6 +83,8 @@ Two categories of operations: The vault enforces this via `PRIVILEGED_OPERATIONS` set + `isInterfaceOrigin()` check. +**Interface screens are modal content, not modals.** A product hosts the interface iframe inside its own modal (Docs and Drive use the design system's 350px small modal; `src/demo` shows the reference wiring) and the interface only lays out the content with the primitives in `src/ui/components/layout/` (`Screen`: illustration, title, description, body, stacked full-width actions; `IdentityCard` + `FingerprintBoxes`; `layout.module.css`, a CSS module whose keys are typed by the generated `layout.module.css.d.ts`: run `npm run css:types` after editing the stylesheet, `src/build/css-module-types.test.ts` fails when it is stale, and a removed class then fails `tsc` at every call site). The product's modal gives the iframe its box with **no padding** (`.c__modal__scroller { padding: 0 }` scoped to the host): the interface pads its screens itself (`.host`), so its back link can hang into that padding the way the product's cross hangs into the modal's, and it asks for the modal width it needs through `MSG_INTERFACE_HOST_SIZE`, relayed by the SDK as the `interface:size` event (`'small'` = 350px, `'medium'` = 600px, the latter for the emergency access lists), which Docs, Drive and the demo apply to their modal's `size`. The product's close control must call `vaultClient.requestClose()` (`MSG_INTERFACE_REQUEST_CLOSE`): the screen shown may hold a guard (`useCloseGuard`, e.g. an unsaved recovery phrase asks "cancel the setup?") and the interface confirms with `MSG_INTERFACE_CLOSED` once it has really closed, so the product unmounts its modal on `interface:closed`, never on the click. The SDK-owned overlays (verify recipients, emergency prompt) draw their own Cunningham Modal instead. In Storybook, `parameters.hostModal` (`true` = 350px, `'medium'` = 600px, both with the product's close control; `'card'` = the 350px width alone, for a component that lives inside a screen) stands in for the product's modal. + ## Security measures - CSP, COEP, COOP, CORP headers (vault is the most restrictive) diff --git a/README.md b/README.md index 3a77a17..5d5c597 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ psql "$ADMIN_DATABASE_URL" -v password="'…'" -f deploy/postgres/create-migrato psql "$ADMIN_DATABASE_URL" -v password="'…'" -f deploy/postgres/create-runtime-role.sql ``` -The split means a compromised server process cannot change the schema, and a schema change cannot happen by accident from a pod: it only happens where the migrator credentials are, which is the release step. Every table lives in the `encryption` schema, never in `public`, so other roles on a shared server cannot even list them. Both connection strings carry `?schema=encryption`: the migration tooling reads it to place its own `_prisma_migrations` table there (the migrator has no right to create anything in `public`), and the server ignores it since its queries name the schema explicitly. +The split means a compromised server process cannot change the schema, and a schema change cannot happen by accident from a pod: it only happens where the migrator credentials are, which is the release step. Every table lives in the `encryption` schema, never in `public`, so other roles on a shared server cannot even list them. Both connection strings carry `?schema=encryption`: the migration tooling reads it to place the tables and its own `_prisma_migrations` table there (the migrator has no right to create anything in `public`), and the server reads it to query that schema. A local database can leave it out and use `public`. ### Migrations diff --git a/deploy/helm/__snapshots__/suite-stack.test.ts.snap b/deploy/helm/__snapshots__/suite-stack.test.ts.snap index 8cef5d1..d111f03 100644 --- a/deploy/helm/__snapshots__/suite-stack.test.ts.snap +++ b/deploy/helm/__snapshots__/suite-stack.test.ts.snap @@ -353,7 +353,8 @@ stringData: CREATE SCHEMA encryption AUTHORIZATION encryption_migrator; - -- The first migration opens with \`CREATE SCHEMA IF NOT EXISTS "encryption"\`, and + -- Prisma Migrate opens with \`CREATE SCHEMA IF NOT EXISTS "encryption"\` (the schema named + -- in the connection string), and -- PostgreSQL checks the CREATE privilege on the database BEFORE looking at whether the -- schema already exists, so without this the migration fails with "permission denied -- for database" even though the schema is there. \`:DBNAME\` is the database psql is diff --git a/deploy/helm/suite-stack/files/postgres/create-migrator-role.sql b/deploy/helm/suite-stack/files/postgres/create-migrator-role.sql index eea5af4..7b8a4ee 100644 --- a/deploy/helm/suite-stack/files/postgres/create-migrator-role.sql +++ b/deploy/helm/suite-stack/files/postgres/create-migrator-role.sql @@ -15,7 +15,8 @@ CREATE ROLE encryption_migrator LOGIN PASSWORD :password; CREATE SCHEMA encryption AUTHORIZATION encryption_migrator; --- The first migration opens with `CREATE SCHEMA IF NOT EXISTS "encryption"`, and +-- Prisma Migrate opens with `CREATE SCHEMA IF NOT EXISTS "encryption"` (the schema named +-- in the connection string), and -- PostgreSQL checks the CREATE privilege on the database BEFORE looking at whether the -- schema already exists, so without this the migration fails with "permission denied -- for database" even though the schema is there. `:DBNAME` is the database psql is diff --git a/deploy/postgres/create-migrator-role.sql b/deploy/postgres/create-migrator-role.sql index eea5af4..7b8a4ee 100644 --- a/deploy/postgres/create-migrator-role.sql +++ b/deploy/postgres/create-migrator-role.sql @@ -15,7 +15,8 @@ CREATE ROLE encryption_migrator LOGIN PASSWORD :password; CREATE SCHEMA encryption AUTHORIZATION encryption_migrator; --- The first migration opens with `CREATE SCHEMA IF NOT EXISTS "encryption"`, and +-- Prisma Migrate opens with `CREATE SCHEMA IF NOT EXISTS "encryption"` (the schema named +-- in the connection string), and -- PostgreSQL checks the CREATE privilege on the database BEFORE looking at whether the -- schema already exists, so without this the migration fails with "permission denied -- for database" even though the schema is there. `:DBNAME` is the database psql is diff --git a/package.json b/package.json index 985d511..f85bd6a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dev:demo:product-a": "DEMO_PORT=7201 vite --config src/demo/vite.config.ts", "dev:demo:product-b": "DEMO_PORT=7202 vite --config src/demo/vite.config.ts", "dev:storybook": "storybook dev -p 7204 --no-open", + "css:types": "tsx src/build/css-module-types.ts", "build:storybook": "storybook build", "start:storybook": "serve -l 7204 --no-request-logging storybook-static", "build:prepare": "npm run db:schema:compile", diff --git a/src/build/css-module-types.test.ts b/src/build/css-module-types.test.ts new file mode 100644 index 0000000..773f679 --- /dev/null +++ b/src/build/css-module-types.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { CSS_MODULES, cssModuleClassKeys, cssModuleDeclaration, declarationPath } from '@encryption/src/build/css-module-types'; + +describe('css module types', () => { + it('turns BEM selectors into the keys Vite exports and ignores global ones', () => { + const css = ` + .screen__title--error { color: red; } + .actions > :global(.c__button) { width: 100%; } + .chip :global(.material-icons) { font-size: 16px; } + /* .commented { } */ + .card, .card--warning:hover { border: 0; } + `; + + expect(cssModuleClassKeys(css)).toEqual(['actions', 'card', 'cardWarning', 'chip', 'screenTitleError']); + }); + + for (const cssPath of CSS_MODULES) { + it(`${declarationPath(cssPath)} is up to date (run \`npm run css:types\`)`, () => { + expect(readFileSync(declarationPath(cssPath), 'utf8')).toBe(cssModuleDeclaration(readFileSync(cssPath, 'utf8'))); + }); + } +}); diff --git a/src/build/css-module-types.ts b/src/build/css-module-types.ts new file mode 100644 index 0000000..13efeef --- /dev/null +++ b/src/build/css-module-types.ts @@ -0,0 +1,54 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * The class names a CSS module exports, as the keys Vite produces for it with + * `localsConvention: 'camelCaseOnly'`. Selectors inside `:global()` belong to + * other stylesheets and are left out. + */ +export function cssModuleClassKeys(css: string): string[] { + const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const withoutGlobals = withoutComments.replace(/:global\([^)]*\)/g, ''); + const keys = new Set(); + + for (const match of withoutGlobals.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)) { + keys.add(camelCase(match[1])); + } + + return [...keys].sort(); +} + +export function cssModuleDeclaration(css: string): string { + const lines = cssModuleClassKeys(css).map((key) => ` readonly ${key}: string;`); + + return `// Generated by \`npm run css:types\` from the stylesheet next to it. Do not edit. +declare const styles: { +${lines.join('\n')} +}; + +export default styles; +`; +} + +// Matches the transform Vite applies (postcss-modules' camelCase): every run of +// non-alphanumerics is a word boundary. +function camelCase(name: string): string { + return name + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((word, index) => (index === 0 ? word : word[0].toUpperCase() + word.slice(1))) + .join(''); +} + +export const CSS_MODULES = ['src/ui/components/layout/layout.module.css']; + +export function declarationPath(cssPath: string): string { + return `${cssPath}.d.ts`; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + for (const cssPath of CSS_MODULES) { + writeFileSync(declarationPath(cssPath), cssModuleDeclaration(readFileSync(cssPath, 'utf8'))); + } +} diff --git a/src/client/vault-client.ts b/src/client/vault-client.ts index e99542f..2805086 100644 --- a/src/client/vault-client.ts +++ b/src/client/vault-client.ts @@ -1,7 +1,9 @@ import { MSG_INTERFACE_CLOSED, MSG_INTERFACE_CONTEXT, + MSG_INTERFACE_HOST_SIZE, MSG_INTERFACE_ONBOARDING_COMPLETE, + MSG_INTERFACE_REQUEST_CLOSE, MSG_INTERFACE_REQUEST_CONTEXT, MSG_INTERFACE_RESIZE, MSG_INTERFACE_SET_THEME, @@ -90,6 +92,12 @@ export interface EncryptionClientEventMap { 'onboarding:complete': { publicKey: string }; /** Fired when the user cancels or closes the interface */ [MSG_INTERFACE_CLOSED]: void; + /** + * Fired when the shown screen wants a modal of another width: the product + * switches its modal between the design system's small (350px) and medium + * (600px) sizes. Emitted on every screen change, so a product can also ignore it. + */ + 'interface:size': { size: 'small' | 'medium' }; /** Fired on errors from the vault or the interface */ error: Error; /** Fired when keys changed from another tab/product (via BroadcastChannel) */ @@ -756,6 +764,27 @@ export class VaultClient { this.sendContext(this.interfaceIframe?.contentWindow); } + /** + * Ask the interface to close, from the product's own close control (the X of + * the modal hosting the iframe). The interface owns the decision: mid-backup + * it shows its "cancel setup?" confirmation instead of closing, so a product + * must NOT unmount its modal here. It waits for the 'interface:closed' event, + * which fires once the interface has really closed (for this request or any + * other reason). With no interface open this is a no-op that still emits + * 'interface:closed', so a product's close handler stays uniform. + */ + requestClose(): void { + const target = this.interfaceIframe?.contentWindow; + + if (!target) { + this.emit(MSG_INTERFACE_CLOSED, undefined as never); + + return; + } + + target.postMessage({ type: MSG_INTERFACE_REQUEST_CLOSE }, this.interfaceOrigin); + } + /** * Close the interface iframe if it is open. */ @@ -795,6 +824,9 @@ export class VaultClient { this.closeInterface(); this.interfaceIframe = this.buildInterfaceIframe(path); + // A placeholder height while the interface loads, so the product's modal does + // not open collapsed; dropped on the first size report, otherwise a screen + // shorter than this shows the difference as empty space under its content. this.interfaceIframe.style.minHeight = '300px'; container.appendChild(this.interfaceIframe); } @@ -827,6 +859,9 @@ export class VaultClient { iframe.style.width = '100%'; iframe.style.border = 'none'; iframe.style.overflow = 'hidden'; + // The frame is sized to its content (see MSG_INTERFACE_RESIZE); its own + // viewport must never scroll, or a rounding pixel shows up as a scrollbar. + iframe.setAttribute('scrolling', 'no'); // Sandbox permissions (principle of least privilege): // - allow-scripts: required for the React app and OIDC client // - allow-same-origin: required for sessionStorage (OIDC state) @@ -1157,7 +1192,7 @@ export class VaultClient { return; } - const msg = data as { type: string; publicKey?: string; height?: number; outcome?: 'resolved' | 'cancelled' }; + const msg = data as { type: string; publicKey?: string; height?: number; size?: string; outcome?: 'resolved' | 'cancelled' }; switch (msg.type) { case MSG_INTERFACE_REQUEST_CONTEXT: @@ -1176,9 +1211,13 @@ export class VaultClient { // its own modal, so collapsing it to content height would break the // backdrop. if (msg.height && this.interfaceIframe && !this.verifyOverlay) { + this.interfaceIframe.style.minHeight = ''; this.interfaceIframe.style.height = `${Math.ceil(msg.height)}px`; } break; + case MSG_INTERFACE_HOST_SIZE: + this.emit('interface:size', { size: msg.size === 'medium' ? 'medium' : 'small' }); + break; case MSG_INTERFACE_ONBOARDING_COMPLETE: this.emit('onboarding:complete', { publicKey: msg.publicKey ?? '' }); break; diff --git a/src/demo/DemoApp.tsx b/src/demo/DemoApp.tsx index b768c6b..df89041 100644 --- a/src/demo/DemoApp.tsx +++ b/src/demo/DemoApp.tsx @@ -1,4 +1,4 @@ -import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; +import { CunninghamProvider, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { VaultClient } from '@encryption/src/client/vault-client'; @@ -86,6 +86,8 @@ function base64ToAb(s: string): ArrayBuffer { export function DemoApp() { const [vaultClient] = useState(() => new VaultClient({ vaultUrl: VAULT_URL, interfaceUrl: INTERFACE_URL, theme: 'light' })); const [interfaceOpen, setInterfaceOpen] = useState(false); + // The interface asks for the modal width its screen needs (see integration.mdx). + const [interfaceSize, setInterfaceSize] = useState(ModalSize.SMALL); const [state, setState] = useState('not-connected'); const [currentUser, setCurrentUser] = useState(null); const currentUserRef = useRef(null); @@ -106,7 +108,6 @@ export function DemoApp() { }, [allDocuments, currentUser, currentKeycloakId]); const [newDocTitle, setNewDocTitle] = useState(''); const [newDocContent, setNewDocContent] = useState(''); - const [interfaceContainer, setInterfaceContainer] = useState(null); const [shareDoc, setShareDoc] = useState(null); // A standing prompt raised when a vault operation fails with a code that needs // user action (integrity failure, missing keys, ...). Persists across the raw @@ -171,9 +172,11 @@ export function DemoApp() { setState('ready'); log(`Onboarding complete. Public key: ${publicKey.slice(0, 30)}...`); }); + client.on('interface:size', ({ size }) => setInterfaceSize(size === 'medium' ? ModalSize.MEDIUM : ModalSize.SMALL)); client.on('interface:closed', () => { log('Interface closed'); setInterfaceOpen(false); + setInterfaceSize(ModalSize.SMALL); // Re-check key state after the interface closes (keys may have been created/deleted) client .hasKeys() @@ -275,19 +278,33 @@ export function DemoApp() { [log, refreshLoginState, vaultClient] ); + // The host modal mounts its container only once open, so the screen to show + // is remembered and the SDK is pointed at the container as soon as it appears. + const pendingScreenRef = useRef<'onboarding' | 'settings' | null>(null); + + const hostContainerRef = useCallback( + (el: HTMLDivElement | null) => { + if (!el || !pendingScreenRef.current) return; + + if (pendingScreenRef.current === 'onboarding') vaultClient.openOnboarding(el); + else vaultClient.openSettings(el); + + pendingScreenRef.current = null; + }, + [vaultClient] + ); + const handleOpenOnboarding = useCallback(() => { - if (!interfaceContainer) return; - vaultClient.openOnboarding(interfaceContainer); + pendingScreenRef.current = 'onboarding'; setInterfaceOpen(true); log('Opening onboarding interface...'); - }, [interfaceContainer, log, vaultClient]); + }, [log]); const handleOpenSettings = useCallback(() => { - if (!interfaceContainer) return; - vaultClient.openSettings(interfaceContainer); + pendingScreenRef.current = 'settings'; setInterfaceOpen(true); log('Opening settings...'); - }, [interfaceContainer, log, vaultClient]); + }, [log]); const handleCreateDocument = useCallback(async () => { const client = vaultClient; @@ -555,11 +572,22 @@ export function DemoApp() {
{/* Left: Documents + Interface container */}
- {/* Interface iframe container — only visible when an interface is open */} -
-

Encryption interface

-
-
+ {/* The interface iframe lives in a modal the PRODUCT draws, like Docs + and Drive do: the modal provides the card, its padding and the + close control. That control only ASKS the interface to close + (requestClose): mid-backup the interface answers with its own + confirmation, and the modal goes away on 'interface:closed'. The + container element stays mounted so the SDK can attach to it + before the modal is even open. */} + vaultClient.requestClose()} + closeOnClickOutside={false} + size={interfaceSize} + aria-label="Encryption" + > +
+ {/* Create document — hidden while the onboarding/settings interface is open, so it only appears once encryption is fully set up. */} diff --git a/src/demo/ModalNoKey.tsx b/src/demo/ModalNoKey.tsx index abf7371..b0c9905 100644 --- a/src/demo/ModalNoKey.tsx +++ b/src/demo/ModalNoKey.tsx @@ -1,4 +1,5 @@ import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; +import { Icon } from '@gouvfr-lasuite/ui-kit'; interface ModalNoKeyProps { userName: string; @@ -19,9 +20,7 @@ export function ModalNoKey({ userName, onClose }: ModalNoKeyProps) { rightActions={} title={ - - lock - + Encryption not enabled } diff --git a/src/demo/ShareDocumentModal.tsx b/src/demo/ShareDocumentModal.tsx index 6454249..8ccb267 100644 --- a/src/demo/ShareDocumentModal.tsx +++ b/src/demo/ShareDocumentModal.tsx @@ -13,7 +13,7 @@ * - Documents and accesses are in-memory only (local to the tab). */ import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { QuickSearch, QuickSearchData, QuickSearchGroup, QuickSearchItemTemplate } from '@gouvfr-lasuite/ui-kit'; +import { Icon, QuickSearch, QuickSearchData, QuickSearchGroup, QuickSearchItemTemplate } from '@gouvfr-lasuite/ui-kit'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { RegisteredUser, VaultClient } from '@encryption/src/client/vault-client'; @@ -80,9 +80,7 @@ function VerifyButton({ onClick }: { onClick: () => void }) { whiteSpace: 'nowrap', }} > - - verified_user - + Verify ); @@ -187,10 +185,7 @@ function InviteUserRow({
{onViewProfile && user.encryption_public_key && onViewProfile(user.id)} />} - Add{' '} - - add - + Add
} @@ -255,9 +250,7 @@ function MemberRow({ lineHeight: 1, }} > - - delete - +
} @@ -579,9 +572,7 @@ export function ShareDocumentModal({ display: 'inline-flex', }} > - - verified_user - + )} -
-
-
-
+ +
+ )} ); diff --git a/src/demo/index.html b/src/demo/index.html index bdc2b85..cd9a796 100644 --- a/src/demo/index.html +++ b/src/demo/index.html @@ -14,6 +14,37 @@ overflow: auto !important; min-height: 100%; } + + /* The modal hosting the encryption interface is the design system's + 350px "small" modal; Cunningham's small is 300px. */ + .c__modal--small:has(.demo-encryption-host) { + width: 350px; + } + + /* The interface pads its own screens: the modal gives its iframe the bare box. */ + .c__modal:has(.demo-encryption-host) .c__modal__scroller { + padding: 0; + } + + /* The design system's close control is a zero-height sticky spot inside the + scroller: while the modal does not scroll it sits in the top padding, and once + it scrolls it pins elsewhere, so the cross jumps and the content shows through + around it. Anchoring it to the modal box itself keeps it in the same corner + in every case, and a halo in the modal's colour masks what scrolls under it. */ + .c__modal:has(.demo-encryption-host) .c__modal__close { + position: absolute; + top: 0.4rem; + right: 0.4rem; + height: auto; + z-index: 1; + } + + .c__modal:has(.demo-encryption-host) .c__modal__close .c__button { + top: 0; + right: 0; + background: var(--c--components--modal--background-color); + box-shadow: 0 0 0 4px var(--c--components--modal--background-color); + } diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index eee6574..ac7f372 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -156,73 +156,69 @@ "no_vault_to_restore": "No vault could be restored with this recovery phrase. Make sure you typed every word correctly and that you are signed in to the right account.", "registration_failed": "Registering the public key on the central server failed. Please try again.", "integrity_failed": "Your recovery phrase was correct, but the encryption data returned by the server did not pass its integrity check. It may have been altered. Do not re-enter your phrase, and contact support.", - "session_expired": "Your session has expired. Reload the page to sign back in, then retry this action." + "session_expired": "Your session has expired. Sign in again to continue." }, "unknown": "An unexpected error occurred." }, "onboarding": { "reset_success": "Your previous keys have been disabled (not deleted). You can now set up new ones.", "title_enable": "Enable encryption", - "title_existing": "Existing configuration detected", - "title_restore": "Restore keys", - "title_backup": "Back up your keys", + "title_existing": "Restore your encryption", + "title_restore": "Use my recovery phrase", + "title_backup": "Recovery phrase", "title_generating": "Generating encryption keys…", - "explanation": "Your encryption keys will be stored on this device and backed up, in encrypted form, so you can restore them on another device with your recovery phrase.", - "explanation_backup_prompt": "Right after activation, you will set up your recovery phrase to back up your keys.", - "existing_key_detected": "Your account already has a registered encryption key.", - "existing_server_detected": "The central server detected an existing encryption configuration for your account. If you still have your backup or another device with your keys, you can restore them below.", - "existing_recovery_hint": "If you still have access to your recovery phrase or another connected device, choose one of the options below.", + "explanation": "To enable encryption on your account, you will need to save a recovery phrase in the next step. Without it, you could permanently lose access to your encrypted documents.", + "existing_recovery_hint": "You already enabled encryption on another device. Choose how to restore your keys on this device.", "btn_enable": "Enable encryption", "btn_generating": "Generating…", "btn_cancel": "Cancel", - "btn_restore_from_backup": "Restore with my recovery phrase", - "btn_use_another_device": "Set up using another device", + "btn_restore_from_backup": "Use my recovery phrase", + "btn_use_another_device": "Use another device", "btn_start_new_identity": "Start over with a new identity", "title_previous_identity": "A previous encryption identity was found", - "previous_identity_detected": "Your account already had an encryption identity that is currently disabled. It was likely turned off from another device, or replaced. Its encrypted documents can still be recovered if you have your recovery phrase.", - "previous_identity_hint": "Restore it from your backup to bring it back, or start over with a new identity (your old encrypted documents would then stay locked unless you restore later).", + "previous_identity_detected": "Your account had an encryption identity that is now disabled, probably turned off from another device or replaced. Its encrypted documents can still be recovered with its recovery phrase.", + "previous_identity_hint": "Restore it with that phrase, or start over with a new identity: the old documents then stay locked until you restore.", + "previous_identity_contacts_title": "Lost the phrase? Your trusted contacts can help", + "previous_identity_contacts_text": "Ask one of them to start an emergency recovery: once the waiting period is over, they can hand the phrase back to you.", "invalid_word_hint": "It looks like one or more words are not in the recovery word list. Check the ones highlighted in red for typos.", - "btn_last_resort": "I no longer have access to my keys", - "btn_confirm_reset": "Disable old key and start from scratch", - "title_last_resort": "Start from scratch", - "last_resort_warning": "The old public key will be disabled on the central server and a new one generated. Your previous keys are not deleted, but you will not be able to use them without their recovery phrase.", + "btn_last_resort": "I lost my keys", + "btn_confirm_reset": "Reset encryption", + "title_last_resort": "Reset encryption", + "last_resort_warning": "You will lose access to documents encrypted with your current keys. New keys will be generated so others can share encrypted documents with you again.", "last_resort_consequence_1": "Documents encrypted with the old key will remain inaccessible until you recover its old recovery phrase (they are not deleted).", "last_resort_consequence_2": "Other members of those documents will need to remove you and re-share access after your new keys are created.", "last_resort_consequence_3": "A new key pair will be generated and you will need to back it up.", - "confirm_fingerprint_to_delete": "To confirm, type your current fingerprint below:", + "confirm_fingerprint_to_delete": "To confirm, type your current safety fingerprint:", "reactivate_title": "Reactivate a previous vault?", "reactivate_confirm": "This recovery phrase belongs to a previous vault (created {{date}}). Reactivating it will disable your current vault, which stays recoverable with its own phrase. Continue?", "reactivate_confirm_button": "Reactivate this vault", "reactivate_cancelled": "Reactivation cancelled. Your current vault stays active.", - "restore_placeholder": "Paste your recovery phrase here…", "reveal_words": "Show words", "hide_words": "Hide words", - "btn_restore": "Restore keys", + "btn_restore": "Validate", "btn_restoring": "Restoring…", "btn_back": "Back", "extensions_warning": "Before continuing, check that the browser extensions you have installed are trustworthy. Extensions have broad access to page content and could potentially access your sensitive data. If in doubt, temporarily disable them.", - "backup_success": "Save your recovery phrase now. It will not be shown again. Encryption is only enabled once you confirm below that you have saved it.", - "backup_warning": "This recovery phrase is your backup. Without it, you will not be able to restore your encryption keys if this device is cleared or lost, and your encrypted documents would then become permanently inaccessible.", - "backup_option_copy": "Copy to a password manager", - "backup_option_copy_description": "Copy the phrase below and paste it into your password manager (with a physical 2FA key like a security key). This is the safest and most convenient way to restore your keys.", - "badge_recommended": "Recommended", - "backup_option_file": "Save to a file", - "backup_option_file_description": "Download a text file containing your recovery phrase. Store it in a secure location (encrypted USB drive, external hard drive).", - "backup_option_print": "Print on paper", - "backup_option_print_description": "Print your recovery phrase and keep it in a safe physical location. Warning: anyone with access to the paper can restore your keys.", - "btn_copy_clipboard": "Copy to clipboard", - "btn_copied": "Copied!", - "btn_save_file": "Download file", + "btn_copy_clipboard": "Copy the phrase", + "btn_copied": "Copied", + "btn_save_file": "Download as a file", "btn_print": "Print", "print_title": "Recovery Phrase (Encryption)", "print_warning": "CONFIDENTIAL. Keep this document in a safe place. Anyone with this phrase could eventually access your encrypted documents.", "print_label": "Your recovery phrase:", "print_footer": "Generated following encryption activation on {{domain}}.", - "reveal_description": "You can also reveal the recovery phrase to copy it manually.", - "btn_reveal": "Reveal recovery phrase", - "passphrase_label": "Recovery phrase (never share it):", - "btn_backup_done": "I have backed up my keys", - "finalizing": "Finalizing…" + "btn_reveal": "Show the phrase", + "btn_backup_done": "It's saved", + "finalizing": "Finalizing…", + "btn_continue": "Continue", + "last_resort_alert": "If you are the only person with full access to an encrypted document, it may be lost for good.", + "i_understand": "I understand.", + "restore_description": "Enter the recovery phrase you saved when enabling encryption.", + "backup_description": "Store this phrase somewhere safe, for example in a password manager. You will need it to restore access to your encrypted documents on a new device. It will not be shown again.", + "cancel_setup_title": "Cancel encryption setup", + "cancel_setup_text": "You have not saved your recovery phrase yet. If you close now, encryption will not be enabled.", + "btn_go_back": "Go back", + "btn_close_anyway": "Close anyway" }, "demo": { "title": "Fake Product (Encryption Demo)", @@ -265,18 +261,15 @@ "title": "Encryption settings", "loading": "Loading…", "no_keys": "No encryption keys are configured on this device. Enable encryption from a LaSuite product to get started.", - "fingerprint_label": "Your safety fingerprint", - "reveal_fingerprint": "Show my safety fingerprint", - "safety_fingerprint_hint": "This is your own identity's safety fingerprint. When someone wants to be sure they are really sharing with you, read these digits to them or show them side by side, so they can confirm they match what they see for you.", - "remote_disabled_warning": "This device's encryption identity is no longer active on the server. It was likely disabled from another device or browser signed in as you. This device still works with its local keys, and your backup can restore it, but other users can no longer share new documents with you until it is re-enabled.", + "safety_fingerprint_hint": "Share this code when someone asks to verify your encryption identity.", + "remote_disabled_warning": "Your keys are still on this device, but encryption is no longer enabled on your account. No one can share new encrypted documents with you.", "remote_diverged_warning": "The encryption identity registered on the server for your account is different from this device's. Another device signed in as you may have changed or replaced it. Until this is resolved, this device and the server disagree about your identity.", "remote_never_warning": "This device has local encryption keys that were never registered on the server. This usually means an earlier setup did not finish. These keys cannot be re-enabled, because there is no vault behind them: the only safe option is to remove them and set up encryption again from scratch.", "remote_never_hint": "Removing these keys clears this device's unfinished setup so you can start over cleanly. If you have a recovery kit from a completed setup, you can restore from it afterwards.", "reconcile_onboard_fresh": "Remove these keys and set up encryption again", "remote_this_device": "This device", "remote_server": "Registered on the server", - "reconcile_title": "Resolve your encryption identity", - "reconcile_reenable": "Re-enable this device's identity", + "reconcile_reenable": "Re-enable encryption", "reconcile_keep_local": "Keep this device's identity", "reconcile_adopt_server": "Use the server's identity instead", "reconcile_delete_local": "Remove encryption from this device", @@ -284,15 +277,17 @@ "reconcile_working": "Applying…", "reconcile_reenable_hint": "Re-enabling re-registers this device's keys on the server so others can share with you again. Nothing on this device changes.", "reconcile_diverged_hint": "Keeping this device's identity re-registers it as the active one and disables the identity the other device set, which will then need to reconcile in turn. Using the server's identity discards this device's local keys and re-acquires the active one from another device or your recovery phrase.", - "reconcile_adopt_confirm": "This will delete this device's local encryption keys and set it up again from the server's identity. Continue?", - "add_device": "Add another device", + "reconcile_adopt_confirm": "This device's encryption keys will be deleted, and the identity registered on the server will be set up here instead, from another device or your recovery phrase.", + "reconcile_adopt_title": "Use the server's identity?", + "reconcile_adopt_button": "Delete these keys and continue", + "add_device": "Transfer keys to a new device", "emergency_phrase_live_one": "{{emails}} obtained an emergency recovery phrase for your data, and it still opens it. Changing your recovery phrase below is what cancels it. They stay one of your trusted contacts.", "emergency_phrase_live_other": "These trusted contacts obtained an emergency recovery phrase for your data, and it still opens it: {{emails}}. Changing your recovery phrase below is what cancels them. They stay your trusted contacts.", "change_phrase": "Change my recovery phrase", "change_phrase_emergency_note_one": "This will also cancel the emergency phrase {{emails}} obtained. They stay your trusted contact and a new dormant emergency phrase is set up for them, which nobody can use until a new request goes through the waiting period.", "change_phrase_emergency_note_other": "This will also cancel the emergency phrases obtained by {{emails}}. They stay your trusted contacts and new dormant emergency phrases are set up for them, which nobody can use until a new request goes through the waiting period.", "changing_phrase": "Generating a new phrase…", - "change_phrase_success": "Save your new recovery phrase now. It will not be shown again. The change only takes effect once you confirm below that you have saved it; until then your current phrase keeps working.", + "change_phrase_success": "Store the new phrase somewhere safe. Your previous phrase stops working once you confirm below.", "change_phrase_warning": "Changing your recovery phrase creates a new one. All existing backups of your current phrase (password manager, file, printout) will stop working. You will need to save the new one before it takes effect.", "change_phrase_continue": "Continue", "change_phrase_cancel": "Cancel", @@ -300,17 +295,25 @@ "change_phrase_applying": "Applying…", "change_phrase_committed": "Your recovery phrase has been changed. Your previous phrase and its backups no longer work.", "change_phrase_done": "Back to settings", - "show_danger_zone": "Delete keys from this device…", - "delete_title": "Delete local keys", + "show_danger_zone": "Remove encryption", + "delete_title": "Remove encryption", "delete_warning_backup": "Without a backup and without another device holding these keys, this deletion will be irreversible. You will permanently lose access to your encrypted documents where you are the only member.", - "confirm_data_loss": "I understand that without a backup or another device with the same keys, encrypted documents will become permanently unreadable", - "also_disable_server": "Also disable this public key on the central server, so from now on other users can no longer share documents encrypted for it", - "disable_server_warning": "Only check this option if you no longer wish to use the keys corresponding to this fingerprint. If you plan to restore your keys later from a backup or another device, do not disable the server key.", - "confirm_server_disable": "I understand that disabling the server key will prevent others from sending me encrypted data until I create new keys", - "confirm_fingerprint_prompt": "Enter your key fingerprint above to confirm:", - "delete_button": "Delete keys permanently", + "disable_server_warning": "If you are the only person with full access to an encrypted document, it may be lost for good. Others will no longer be able to share encrypted documents with you.", + "confirm_fingerprint_prompt": "To confirm, type your safety fingerprint:", + "delete_button": "Remove encryption", "deleting": "Deleting…", - "close": "Close" + "close": "Close", + "you": "You", + "key_mismatch": "Identity mismatch", + "remote_disabled_title": "Encryption disabled on your account", + "remote_never_title": "Unfinished encryption setup", + "delete_scope_device": "On this device only", + "delete_scope_device_hint": "You will not be able to open encrypted documents on this device anymore. You can get access back later with your recovery phrase or another device.", + "delete_scope_account": "On my whole account", + "delete_scope_account_hint": "No one will be able to share new encrypted documents with you until you set up encryption again.", + "change_phrase_new_title": "New recovery phrase", + "cancel_change_title": "Keep your current phrase?", + "cancel_change_text": "You have not saved the new recovery phrase yet. If you close now, your current phrase stays valid and nothing changes." }, "emergency": { "title": "Emergency access", @@ -362,9 +365,11 @@ "confirm_request_body": "{{email}} will be notified immediately and can refuse for {{count}} days. Only make this request to help them regain access to their documents: they are informed at every step.", "confirm_request_button": "Confirm the request", "prompt_recovery_title": "Someone requested access to your encrypted data", - "prompt_recovery_body": "{{email}}, one of your trusted contacts, requested emergency access to your encrypted data. If you do nothing, it will be granted to them on {{date}}.", + "prompt_recovery_body": "{{email}}, one of your trusted contacts, requested emergency access to your encrypted data.", + "prompt_recovery_deadline": "If you do nothing, access is granted on", "prompt_recovery_approved": "{{email}}, one of your trusted contacts, requested emergency access to your encrypted data, and the waiting period has elapsed, so it is now available to them.", - "prompt_recovery_hint": "You are signed in and your encryption already works on this device, so this recovery gains you nothing here: it exists to help you back in on a device that lost access. If you did not arrange it with this contact, refuse the request to re-lock it.", + "prompt_recovery_hint": "This recovery is meant to get you back in on a device that lost access. You are signed in here and your encryption already works, so it gains you nothing on this device.", + "prompt_recovery_hint_refuse": "If you did not arrange it with this contact, refuse the request to re-lock your data.", "btn_prompt_refuse": "Refuse the request", "prompt_invite_title": "You have been designated as a trusted contact", "prompt_invite_body": "Someone trusts you to help them regain access to their encrypted documents if they lose their recovery phrase. Review the invitation below to accept or decline it.", @@ -430,14 +435,16 @@ "not_acknowledged": "Signed in, but the encryption interface did not confirm it received the result. Go back to the previous tab and try again, then close this one.", "sign_in_another_account": "Sign in with another account", "no_user_context": "The application did not provide user context. The encryption interface cannot work without knowing which user is authenticated. Please make sure you are logged in to the application.", + "signing_in": "Signing in…", + "finish_in_tab": "Complete the sign-in in the tab that opened.", "reconnect": "Reconnect" }, "device_approval": { - "new_title": "Add this device", + "new_title": "Transfer from another device", "new_intro": "Show this code to a device that already has your keys, so it can grant this one access.", - "btn_start": "Generate a pairing code", + "btn_start": "Show a pairing code", "new_code_hint": "On your other device, scan this QR code with its camera.", - "new_reveal_code": "No camera on the other device? Show a code to type", + "new_reveal_code": "No camera on the other device?\nShow a code to type", "new_manual_hint": "Or, if you cannot scan the QR code, type this code on your other device:", "copy_code": "Copy code", "copied": "Copied", @@ -451,40 +458,41 @@ "manual_intro": "Type the code shown on the new device.", "scan_instead": "Scan the QR code instead", "code_no_match": "That code does not match any device waiting to be added. Check the digits and try again.", - "code_placeholder": "Code (digits only)", - "btn_approve": "Approve", + "btn_approve": "Validate", "approving": "Approving…", "approve_success": "Approved. The new device can now finish setting up.", "btn_cancel": "Cancel", - "btn_back": "Back", - "invalid_code": "That pairing code is not valid." + "new_success_title": "Keys transferred", + "approve_success_title": "Device approved" }, "verify": { - "title": "Verify who you are sharing with", - "explanation": "The encryption identity of these recipients has changed, or you refused it before. To protect against impersonation, confirm them before sharing.", - "compare_instruction": "Compare each safety fingerprint below with the one the recipient reads to you out of band (in person, by phone, or over a trusted channel). Only confirm if they match exactly.", - "refuse_aborts": "This share only goes through if you trust each recipient below. Refusing any of them, or cancelling, cancels the whole share.", + "title": "Verify encryption identity", "trusted": "Trusted", - "note_changed": "This contact's encryption key changed since you last verified it.", - "note_refused": "You previously refused this contact.", + "note_changed": "This person's encryption identity has changed. Verify it before continuing.", + "note_refused": "You previously refused this person's identity. Verify it again before continuing.", "btn_trust": "Trust", - "btn_refuse": "Refuse", + "btn_refuse": "Don't trust", "btn_cancel": "Cancel share", - "btn_confirm": "Share", "recipients_without_keys": "Some recipients have not enabled encryption yet. The encrypted share cannot proceed until they have a key.", "btn_close": "Close", - "nothing_to_verify": "All recipients are already verified. You can confirm to continue." + "compare_instruction_many": "Ask each of them to read out the fingerprint from their own encryption settings (in any LaSuite product), over a trusted channel. Trust only what matches exactly.", + "explanation_many": "The encryption identity of these people changed, or you refused it before. Verify each one before sharing." }, "profile": { "title": "Encryption Identity", - "decision_trusted": "Verified", + "decision_trusted": "Trusted", "decision_refused": "Refused", + "decision_changed": "Identity changed", "compare_instruction": "Ask this contact to read out the fingerprint from their own encryption settings (in any LaSuite product), over a trusted channel. Trust only if it matches exactly.", "no_key": "This contact hasn't set up encryption yet, so there is nothing to verify.", - "btn_trust": "Trust this contact", - "btn_refuse": "Refuse this contact" + "btn_trust": "Trust", + "btn_refuse": "Don't trust" }, "docs": { "disabled": "Documentation is not available on this instance." + }, + "identity": { + "copied": "Copied", + "copy_fingerprint": "Copy the fingerprint" } } diff --git a/src/i18n/fr/common.json b/src/i18n/fr/common.json index fe7d379..2335fcd 100644 --- a/src/i18n/fr/common.json +++ b/src/i18n/fr/common.json @@ -156,7 +156,7 @@ "no_vault_to_restore": "Aucun coffre n'a pu être restauré avec cette phrase de récupération. Vérifiez que vous avez saisi chaque mot correctement et que vous êtes connecté au bon compte.", "registration_failed": "L'enregistrement de la clé publique sur le serveur central a échoué. Veuillez réessayer.", "integrity_failed": "Votre phrase de récupération était correcte, mais les données de chiffrement renvoyées par le serveur n'ont pas passé leur contrôle d'intégrité. Elles ont peut-être été altérées. Ne saisissez pas à nouveau votre phrase et contactez le support.", - "session_expired": "Votre session a expiré. Rechargez la page pour vous reconnecter, puis réessayez." + "session_expired": "Votre session a expiré. Reconnectez-vous pour continuer." }, "unknown": "Une erreur inattendue s'est produite." }, @@ -167,62 +167,58 @@ "reactivate_confirm_button": "Réactiver ce coffre", "reactivate_cancelled": "Réactivation annulée. Votre coffre actuel reste actif.", "title_enable": "Activer le chiffrement", - "title_existing": "Configuration existante détectée", - "title_restore": "Restaurer les clés", - "title_backup": "Sauvegardez vos clés", + "title_existing": "Restaurer votre chiffrement", + "title_restore": "Utiliser ma phrase de récupération", + "title_backup": "Phrase de récupération", "title_generating": "Génération des clés de chiffrement…", - "explanation": "Vos clés de chiffrement seront stockées sur cet appareil et sauvegardées, sous forme chiffrée, afin que vous puissiez les restaurer sur un autre appareil à l'aide de votre phrase de récupération.", - "explanation_backup_prompt": "Juste après l'activation, vous configurerez votre phrase de récupération pour sauvegarder vos clés.", - "existing_key_detected": "Votre compte possède déjà une clé de chiffrement enregistrée.", - "existing_server_detected": "Le serveur central a détecté une configuration de chiffrement existante pour votre compte. Si vous possédez encore votre sauvegarde ou un autre appareil avec vos clés, vous pouvez les restaurer ci-dessous.", - "existing_recovery_hint": "Si vous avez encore accès à votre phrase de récupération ou à un autre appareil connecté, choisissez l'une des options ci-dessous.", + "explanation": "Pour activer le chiffrement sur votre compte, vous devrez conserver une phrase de récupération à l'étape suivante. Sans elle, vous pourriez perdre définitivement l'accès à vos documents chiffrés.", + "existing_recovery_hint": "Vous avez déjà activé le chiffrement sur un autre appareil. Choisissez comment restaurer vos clés sur cet appareil.", "btn_enable": "Activer le chiffrement", "btn_generating": "Génération…", "btn_cancel": "Annuler", - "btn_restore_from_backup": "Restaurer avec ma phrase de récupération", - "btn_use_another_device": "Configurer depuis un autre appareil", + "btn_restore_from_backup": "Utiliser ma phrase de récupération", + "btn_use_another_device": "Utiliser un autre appareil", "btn_start_new_identity": "Repartir avec une nouvelle identité", "title_previous_identity": "Une précédente identité de chiffrement a été trouvée", - "previous_identity_detected": "Votre compte avait déjà une identité de chiffrement, actuellement désactivée. Elle a probablement été désactivée depuis un autre appareil, ou remplacée. Ses documents chiffrés restent récupérables si vous avez votre phrase de récupération.", - "previous_identity_hint": "Restaurez-la depuis votre sauvegarde pour la réactiver, ou repartez avec une nouvelle identité (vos anciens documents chiffrés resteront alors verrouillés tant que vous ne les restaurez pas).", + "previous_identity_detected": "Votre compte avait une identité de chiffrement, aujourd'hui désactivée, probablement depuis un autre appareil ou remplacée. Ses documents chiffrés restent récupérables avec sa phrase de récupération.", + "previous_identity_hint": "Restaurez-la avec cette phrase, ou repartez avec une nouvelle identité : les anciens documents resteront verrouillés tant que vous ne restaurez pas.", + "previous_identity_contacts_title": "Phrase perdue ? Vos contacts de confiance peuvent aider", + "previous_identity_contacts_text": "Demandez à l'un d'eux de lancer une récupération d'urgence : à l'issue du délai d'attente, il pourra vous remettre la phrase.", "invalid_word_hint": "Il semble qu'un ou plusieurs mots ne figurent pas dans la liste de mots de récupération. Vérifiez les mots en rouge (fautes de frappe).", - "btn_last_resort": "Je n'ai plus accès à mes clés", - "btn_confirm_reset": "Désactiver l'ancienne clé et repartir de zéro", - "title_last_resort": "Repartir de zéro", - "last_resort_warning": "L'ancienne clé publique sera désactivée sur le serveur central et une nouvelle sera générée. Vos anciennes clés ne sont pas supprimées, mais vous ne pourrez plus les utiliser sans leur phrase de récupération.", + "btn_last_resort": "J'ai perdu mes clés", + "btn_confirm_reset": "Réinitialiser le chiffrement", + "title_last_resort": "Réinitialiser le chiffrement", + "last_resort_warning": "Vous perdrez l'accès aux documents chiffrés avec vos clés actuelles. De nouvelles clés seront générées pour que les autres puissent à nouveau partager des documents chiffrés avec vous.", "last_resort_consequence_1": "Les documents chiffrés avec l'ancienne clé resteront inaccessibles tant que vous n'aurez pas retrouvé son ancienne phrase de récupération (ils ne sont pas supprimés).", "last_resort_consequence_2": "Les autres membres de ces documents devront vous retirer puis vous repartager l'accès après la création de vos nouvelles clés.", "last_resort_consequence_3": "Une nouvelle paire de clés sera générée et vous devrez la sauvegarder.", - "confirm_fingerprint_to_delete": "Pour confirmer, saisissez votre empreinte actuelle ci-dessous :", - "restore_placeholder": "Collez votre phrase de récupération ici…", + "confirm_fingerprint_to_delete": "Pour confirmer, saisissez votre empreinte de sécurité actuelle :", "reveal_words": "Afficher les mots", "hide_words": "Masquer les mots", - "btn_restore": "Restaurer les clés", + "btn_restore": "Valider", "btn_restoring": "Restauration…", "btn_back": "Retour", "extensions_warning": "Avant de continuer, vérifiez que les extensions installées dans votre navigateur sont de confiance. Les extensions ont un accès étendu au contenu des pages et pourraient potentiellement accéder à vos données sensibles. En cas de doute, désactivez-les temporairement.", - "backup_success": "Sauvegardez votre phrase de récupération maintenant. Elle ne sera plus affichée. Le chiffrement ne sera activé qu'une fois que vous aurez confirmé ci-dessous l'avoir sauvegardée.", - "backup_warning": "Cette phrase de récupération est votre sauvegarde. Sans elle, vous ne pourrez pas restaurer vos clés de chiffrement si cet appareil est effacé ou perdu, et vos documents chiffrés deviendraient alors définitivement inaccessibles.", - "backup_option_copy": "Copier dans un gestionnaire de mots de passe", - "backup_option_copy_description": "Copiez la phrase ci-dessous et collez-la dans votre gestionnaire de mots de passe (avec 2FA physique comme une clé de sécurité). C'est la méthode la plus sûre et la plus pratique pour restaurer vos clés.", - "badge_recommended": "Recommandé", - "backup_option_file": "Enregistrer dans un fichier", - "backup_option_file_description": "Téléchargez un fichier texte contenant votre phrase de récupération. Stockez-le dans un emplacement sécurisé (clé USB chiffrée, disque dur externe).", - "backup_option_print": "Imprimer sur papier", - "backup_option_print_description": "Imprimez votre phrase de récupération et rangez-la dans un lieu physique sûr. Attention : toute personne ayant accès au papier pourra restaurer vos clés.", - "btn_copy_clipboard": "Copier dans le presse-papier", - "btn_copied": "Copié !", - "btn_save_file": "Télécharger le fichier", + "btn_copy_clipboard": "Copier la phrase", + "btn_copied": "Copié", + "btn_save_file": "Télécharger en fichier", "btn_print": "Imprimer", "print_title": "Phrase de récupération (Chiffrement)", "print_warning": "CONFIDENTIEL. Conservez ce document en lieu sûr. Quiconque possède cette phrase pourrait à terme accéder à vos documents chiffrés.", "print_label": "Votre phrase de récupération :", "print_footer": "Généré suite à l'activation du chiffrement sur {{domain}}.", - "reveal_description": "Vous pouvez aussi afficher la phrase de récupération pour la copier manuellement.", - "btn_reveal": "Afficher la phrase de récupération", - "passphrase_label": "Phrase de récupération (ne la partagez jamais) :", - "btn_backup_done": "J'ai sauvegardé mes clés", - "finalizing": "Finalisation…" + "btn_reveal": "Afficher la phrase", + "btn_backup_done": "C'est enregistré", + "finalizing": "Finalisation…", + "btn_continue": "Continuer", + "last_resort_alert": "Si vous êtes la seule personne à avoir un accès complet à un document chiffré, il pourrait être perdu définitivement.", + "i_understand": "Je comprends.", + "restore_description": "Saisissez la phrase de récupération que vous avez conservée lors de l'activation du chiffrement.", + "backup_description": "Conservez cette phrase en lieu sûr, par exemple dans un gestionnaire de mots de passe. Vous en aurez besoin pour retrouver l'accès à vos documents chiffrés sur un nouvel appareil. Elle ne sera plus affichée.", + "cancel_setup_title": "Annuler l'activation du chiffrement", + "cancel_setup_text": "Vous n'avez pas encore enregistré votre phrase de récupération. Si vous fermez maintenant, le chiffrement ne sera pas activé.", + "btn_go_back": "Revenir", + "btn_close_anyway": "Fermer quand même" }, "demo": { "title": "Produit fictif (Démo chiffrement)", @@ -265,18 +261,15 @@ "title": "Paramètres de chiffrement", "loading": "Chargement…", "no_keys": "Aucune clé de chiffrement n'est configurée sur cet appareil. Activez le chiffrement depuis un produit de LaSuite pour commencer.", - "fingerprint_label": "Votre empreinte de sécurité", - "reveal_fingerprint": "Afficher mon empreinte de sécurité", - "safety_fingerprint_hint": "Voici l'empreinte de sécurité de votre propre identité. Lorsqu'une personne veut s'assurer qu'elle partage bien avec vous, lisez-lui ces chiffres ou montrez-les côte à côte, afin qu'elle confirme qu'ils correspondent à ce qu'elle voit pour vous.", - "remote_disabled_warning": "L'identité de chiffrement de cet appareil n'est plus active sur le serveur. Elle a probablement été désactivée depuis un autre appareil ou navigateur connecté avec votre compte. Cet appareil continue de fonctionner avec ses clés locales, et votre sauvegarde permet de la restaurer, mais les autres utilisateurs ne peuvent plus partager de nouveaux documents avec vous tant qu'elle n'est pas réactivée.", + "safety_fingerprint_hint": "Communiquez ce code lorsqu'une personne souhaite vérifier votre identité de chiffrement.", + "remote_disabled_warning": "Vos clés sont toujours sur cet appareil, mais le chiffrement n'est plus activé sur votre compte. Personne ne peut plus partager de nouveaux documents chiffrés avec vous.", "remote_diverged_warning": "L'identité de chiffrement enregistrée sur le serveur pour votre compte est différente de celle de cet appareil. Un autre appareil connecté avec votre compte l'a peut-être changée ou remplacée. Tant que ce n'est pas résolu, cet appareil et le serveur ne sont pas d'accord sur votre identité.", "remote_never_warning": "Cet appareil possède des clés de chiffrement locales qui n'ont jamais été enregistrées sur le serveur. Cela signifie généralement qu'une configuration précédente n'a pas abouti. Ces clés ne peuvent pas être réactivées, car aucun coffre ne les accompagne : la seule option sûre est de les supprimer et de reconfigurer le chiffrement de zéro.", "remote_never_hint": "Supprimer ces clés efface la configuration inachevée de cet appareil pour repartir proprement. Si vous disposez d'un kit de récupération issu d'une configuration terminée, vous pourrez le restaurer ensuite.", "reconcile_onboard_fresh": "Supprimer ces clés et reconfigurer le chiffrement", "remote_this_device": "Cet appareil", "remote_server": "Enregistrée sur le serveur", - "reconcile_title": "Résoudre votre identité de chiffrement", - "reconcile_reenable": "Réactiver l'identité de cet appareil", + "reconcile_reenable": "Réactiver le chiffrement", "reconcile_keep_local": "Conserver l'identité de cet appareil", "reconcile_adopt_server": "Utiliser plutôt l'identité du serveur", "reconcile_delete_local": "Retirer le chiffrement de cet appareil", @@ -284,15 +277,17 @@ "reconcile_working": "Application…", "reconcile_reenable_hint": "La réactivation réenregistre les clés de cet appareil sur le serveur pour que les autres puissent à nouveau partager avec vous. Rien ne change sur cet appareil.", "reconcile_diverged_hint": "Conserver l'identité de cet appareil la réenregistre comme active et désactive celle définie par l'autre appareil, qui devra alors se réconcilier à son tour. Utiliser l'identité du serveur supprime les clés locales de cet appareil et récupère l'identité active depuis un autre appareil ou votre phrase de récupération.", - "reconcile_adopt_confirm": "Cette action supprimera les clés de chiffrement locales de cet appareil et le reconfigurera à partir de l'identité du serveur. Continuer ?", - "add_device": "Ajouter un autre appareil", + "reconcile_adopt_confirm": "Les clés de chiffrement de cet appareil seront supprimées, et l'identité enregistrée sur le serveur sera installée ici à la place, depuis un autre appareil ou votre phrase de récupération.", + "reconcile_adopt_title": "Utiliser l'identité du serveur ?", + "reconcile_adopt_button": "Supprimer ces clés et continuer", + "add_device": "Transférer les clés vers un nouvel appareil", "emergency_phrase_live_one": "{{emails}} a obtenu une phrase de récupération d'urgence pour vos données, et celle-ci les ouvre toujours. C'est le changement de votre phrase de récupération, ci-dessous, qui l'annule. Cette personne reste l'un de vos contacts de confiance.", "emergency_phrase_live_other": "Ces contacts de confiance ont obtenu une phrase de récupération d'urgence pour vos données, et celle-ci les ouvre toujours : {{emails}}. C'est le changement de votre phrase de récupération, ci-dessous, qui les annule. Ces personnes restent vos contacts de confiance.", "change_phrase": "Changer ma phrase de récupération", "change_phrase_emergency_note_one": "Cela annulera aussi la phrase d'urgence obtenue par {{emails}}. Cette personne reste votre contact de confiance et une nouvelle phrase d'urgence dormante est mise en place pour elle, que personne ne peut utiliser tant qu'une nouvelle demande n'a pas passé le délai d'opposition.", "change_phrase_emergency_note_other": "Cela annulera aussi les phrases d'urgence obtenues par {{emails}}. Ces personnes restent vos contacts de confiance et de nouvelles phrases d'urgence dormantes sont mises en place pour elles, que personne ne peut utiliser tant qu'une nouvelle demande n'a pas passé le délai d'opposition.", "changing_phrase": "Génération d'une nouvelle phrase…", - "change_phrase_success": "Sauvegardez votre nouvelle phrase de récupération maintenant. Elle ne sera plus affichée. Le changement ne prend effet qu'une fois que vous aurez confirmé ci-dessous l'avoir sauvegardée ; d'ici là, votre phrase actuelle reste valable.", + "change_phrase_success": "Conservez la nouvelle phrase en lieu sûr. Votre ancienne phrase cessera de fonctionner dès que vous confirmerez ci-dessous.", "change_phrase_warning": "Changer votre phrase de récupération en génère une nouvelle. Toutes les sauvegardes de votre phrase actuelle (gestionnaire de mots de passe, fichier, impression) cesseront de fonctionner. Vous devrez sauvegarder la nouvelle avant qu'elle ne prenne effet.", "change_phrase_continue": "Continuer", "change_phrase_cancel": "Annuler", @@ -300,17 +295,25 @@ "change_phrase_applying": "Application…", "change_phrase_committed": "Votre phrase de récupération a été changée. Votre ancienne phrase et ses sauvegardes ne fonctionnent plus.", "change_phrase_done": "Retour aux paramètres", - "show_danger_zone": "Supprimer les clés de cet appareil…", - "delete_title": "Supprimer les clés locales", + "show_danger_zone": "Retirer le chiffrement", + "delete_title": "Retirer le chiffrement", "delete_warning_backup": "Sans sauvegarde et sans autre appareil possédant ces clés, cette suppression sera irréversible. Vous perdrez définitivement l'accès à vos documents chiffrés dont vous êtes le seul membre.", - "confirm_data_loss": "Je comprends que sans sauvegarde ni autre appareil possédant les mêmes clés, les documents chiffrés deviendront définitivement illisibles", - "also_disable_server": "Désactiver aussi cette clé publique sur le serveur central, afin qu'à partir de maintenant les autres utilisateurs ne puissent plus partager de documents chiffrés pour cette clé", - "disable_server_warning": "Ne cochez cette option que si vous ne souhaitez définitivement plus utiliser les clés correspondant à cette empreinte. Si vous comptez restaurer vos clés plus tard depuis une sauvegarde ou un autre appareil, ne désactivez pas la clé serveur.", - "confirm_server_disable": "Je comprends que la désactivation serveur empêchera les autres de m'envoyer des données chiffrées jusqu'à ce que je recrée des clés", - "confirm_fingerprint_prompt": "Saisissez l'empreinte de votre clé ci-dessus pour confirmer :", - "delete_button": "Supprimer les clés définitivement", + "disable_server_warning": "Si vous êtes la seule personne à avoir un accès complet à un document chiffré, il pourrait être perdu définitivement. Les autres ne pourront plus partager de documents chiffrés avec vous.", + "confirm_fingerprint_prompt": "Pour confirmer, saisissez votre empreinte de sécurité :", + "delete_button": "Retirer le chiffrement", "deleting": "Suppression…", - "close": "Fermer" + "close": "Fermer", + "you": "Vous", + "key_mismatch": "Identité différente", + "remote_disabled_title": "Chiffrement désactivé sur votre compte", + "remote_never_title": "Activation du chiffrement inachevée", + "delete_scope_device": "Sur cet appareil uniquement", + "delete_scope_device_hint": "Vous ne pourrez plus ouvrir de documents chiffrés sur cet appareil. Vous pourrez retrouver l'accès plus tard avec votre phrase de récupération ou un autre appareil.", + "delete_scope_account": "Sur l'ensemble de mon compte", + "delete_scope_account_hint": "Personne ne pourra plus partager de nouveaux documents chiffrés avec vous tant que vous n'aurez pas réactivé le chiffrement.", + "change_phrase_new_title": "Nouvelle phrase de récupération", + "cancel_change_title": "Conserver votre phrase actuelle ?", + "cancel_change_text": "Vous n'avez pas encore enregistré la nouvelle phrase de récupération. Si vous fermez maintenant, votre phrase actuelle reste valable et rien ne change." }, "emergency": { "title": "Accès d'urgence", @@ -362,9 +365,11 @@ "confirm_request_body": "{{email}} sera notifié immédiatement et pourra refuser pendant {{count}} jours. Ne faites cette demande que pour aider cette personne à retrouver l'accès à ses documents : elle est prévenue à chaque étape.", "confirm_request_button": "Confirmer la demande", "prompt_recovery_title": "Quelqu'un a demandé l'accès à vos données chiffrées", - "prompt_recovery_body": "{{email}}, l'un de vos contacts de confiance, a demandé l'accès d'urgence à vos données chiffrées. Sans action de votre part, il lui sera accordé le {{date}}.", + "prompt_recovery_body": "{{email}}, l'un de vos contacts de confiance, a demandé l'accès d'urgence à vos données chiffrées.", + "prompt_recovery_deadline": "Sans action de votre part, l'accès est accordé le", "prompt_recovery_approved": "{{email}}, l'un de vos contacts de confiance, a demandé l'accès d'urgence à vos données chiffrées, et le délai d'attente est écoulé : l'accès lui est désormais ouvert.", - "prompt_recovery_hint": "Vous êtes connecté et votre chiffrement fonctionne déjà sur cet appareil : cette récupération ne vous apporte rien ici, elle sert à vous rétablir l'accès sur un appareil qui l'aurait perdu. Si vous n'êtes pas à l'origine de cette démarche avec ce contact, refusez la demande pour la reverrouiller.", + "prompt_recovery_hint": "Cette récupération sert à vous rétablir l'accès sur un appareil qui l'aurait perdu. Vous êtes connecté ici et votre chiffrement fonctionne déjà : elle ne vous apporte rien sur cet appareil.", + "prompt_recovery_hint_refuse": "Si vous n'êtes pas à l'origine de cette démarche avec ce contact, refusez la demande pour reverrouiller vos données.", "btn_prompt_refuse": "Refuser la demande", "prompt_invite_title": "Vous avez été désigné comme contact de confiance", "prompt_invite_body": "Une personne vous fait confiance pour l'aider à récupérer l'accès à ses documents chiffrés si elle perd sa phrase de récupération. Consultez l'invitation ci-dessous pour l'accepter ou la décliner.", @@ -430,14 +435,16 @@ "not_acknowledged": "Connexion réussie, mais l'interface de chiffrement n'a pas confirmé avoir reçu le résultat. Retournez à l'onglet précédent et réessayez, puis fermez celui-ci.", "sign_in_another_account": "Se connecter avec un autre compte", "no_user_context": "L'application n'a pas fourni le contexte utilisateur. L'interface de chiffrement ne peut pas fonctionner sans savoir quel utilisateur est authentifié. Veuillez vous assurer d'être connecté à l'application.", + "signing_in": "Connexion…", + "finish_in_tab": "Terminez la connexion dans l'onglet qui s'est ouvert.", "reconnect": "Se reconnecter" }, "device_approval": { - "new_title": "Ajouter cet appareil", + "new_title": "Transférer depuis un autre appareil", "new_intro": "Montrez ce code à un appareil qui possède déjà vos clés, afin qu'il autorise celui-ci.", - "btn_start": "Générer un code d'appairage", + "btn_start": "Afficher un code d'appairage", "new_code_hint": "Sur votre autre appareil, scannez ce QR code avec sa caméra.", - "new_reveal_code": "Pas de caméra sur l'autre appareil ? Afficher un code à saisir", + "new_reveal_code": "Pas de caméra sur l'autre appareil ?\nAfficher un code à saisir", "new_manual_hint": "Ou, si vous ne pouvez pas scanner le QR code, saisissez ce code sur votre autre appareil :", "copy_code": "Copier le code", "copied": "Copié", @@ -451,40 +458,41 @@ "manual_intro": "Saisissez le code affiché sur le nouvel appareil.", "scan_instead": "Scanner le QR code à la place", "code_no_match": "Ce code ne correspond à aucun appareil en attente d'ajout. Vérifiez les chiffres et réessayez.", - "code_placeholder": "Code (chiffres uniquement)", - "btn_approve": "Approuver", + "btn_approve": "Valider", "approving": "Approbation…", "approve_success": "Approuvé. Le nouvel appareil peut maintenant terminer sa configuration.", "btn_cancel": "Annuler", - "btn_back": "Retour", - "invalid_code": "Ce code d'appairage n'est pas valide." + "new_success_title": "Clés transférées", + "approve_success_title": "Appareil approuvé" }, "verify": { - "title": "Vérifiez avec qui vous partagez", - "explanation": "L'identité de chiffrement de ces destinataires a changé, ou vous l'aviez refusée. Pour vous protéger contre l'usurpation d'identité, confirmez-les avant de partager.", - "compare_instruction": "Comparez chaque empreinte de sécurité ci-dessous avec celle que le destinataire vous communique par un autre canal (en personne, par téléphone ou via un canal de confiance). Ne confirmez que si elles correspondent exactement.", - "refuse_aborts": "Ce partage n'aboutit que si vous faites confiance à chaque destinataire ci-dessous. En refuser un, ou annuler, annule l'intégralité du partage.", - "trusted": "Approuvé", - "note_changed": "La clé de chiffrement de ce contact a changé depuis votre dernière vérification.", - "note_refused": "Vous avez précédemment refusé ce contact.", + "title": "Vérifier l'identité de chiffrement", + "trusted": "Vérifié", + "note_changed": "L'identité de chiffrement de cette personne a changé. Vérifiez-la avant de continuer.", + "note_refused": "Vous aviez refusé l'identité de cette personne. Vérifiez-la à nouveau avant de continuer.", "btn_trust": "Faire confiance", - "btn_refuse": "Refuser", + "btn_refuse": "Ne pas faire confiance", "btn_cancel": "Annuler le partage", - "btn_confirm": "Partager", "recipients_without_keys": "Certains destinataires n'ont pas encore activé le chiffrement. Le partage chiffré est impossible tant qu'ils n'ont pas de clé.", "btn_close": "Fermer", - "nothing_to_verify": "Tous les destinataires sont déjà vérifiés. Vous pouvez confirmer pour continuer." + "compare_instruction_many": "Demandez à chacune d'elles de lire l'empreinte depuis ses propres réglages de chiffrement (dans n'importe quel produit de LaSuite), par un canal de confiance. N'approuvez que ce qui correspond exactement.", + "explanation_many": "L'identité de chiffrement de ces personnes a changé, ou vous l'aviez refusée. Vérifiez chacune d'elles avant de partager." }, "profile": { "title": "Identité de chiffrement", "decision_trusted": "Vérifié", "decision_refused": "Refusé", + "decision_changed": "Identité modifiée", "compare_instruction": "Demandez à ce contact de lire l'empreinte depuis ses propres réglages de chiffrement (dans n'importe quel produit de LaSuite), par un canal de confiance. N'approuvez que si elle correspond exactement.", "no_key": "Ce contact n'a pas encore activé le chiffrement, il n'y a donc rien à vérifier.", - "btn_trust": "Approuver ce contact", - "btn_refuse": "Refuser ce contact" + "btn_trust": "Faire confiance", + "btn_refuse": "Ne pas faire confiance" }, "docs": { "disabled": "La documentation n'est pas disponible sur cette instance." + }, + "identity": { + "copied": "Copié", + "copy_fingerprint": "Copier l'empreinte" } } diff --git a/src/prisma/client.ts b/src/prisma/client.ts index 124215a..04e099f 100644 --- a/src/prisma/client.ts +++ b/src/prisma/client.ts @@ -2,8 +2,22 @@ import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@encryption/src/generated/prisma/client'; +/** + * The PostgreSQL schema the tables live in, read from the `?schema=` parameter of + * the connection string, as the migration tooling reads it. Deployments put it + * in a dedicated schema (see README); a local database can simply use `public`. + */ +export function databaseSchema(databaseUrl: string | undefined): string { + // No URL means no database in this process (unit tests import route modules + // without one): the client is created lazily and never connects. + if (!databaseUrl) return 'public'; + + return new URL(databaseUrl).searchParams.get('schema') ?? 'public'; +} + function createPrismaClient(): PrismaClient { - const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); + const url = process.env.DATABASE_URL; + const adapter = new PrismaPg({ connectionString: url }, { schema: databaseSchema(url) }); return new PrismaClient({ adapter, diff --git a/src/prisma/migrations/20260921153450_baseline/migration.sql b/src/prisma/migrations/20260921153450_baseline/migration.sql index 37f3cab..7e2481c 100644 --- a/src/prisma/migrations/20260921153450_baseline/migration.sql +++ b/src/prisma/migrations/20260921153450_baseline/migration.sql @@ -1,23 +1,20 @@ --- CreateSchema -CREATE SCHEMA IF NOT EXISTS "encryption"; - -- CreateEnum -CREATE TYPE "encryption"."SignatureAlgo" AS ENUM ('ed25519'); +CREATE TYPE "SignatureAlgo" AS ENUM ('ed25519'); -- CreateEnum -CREATE TYPE "encryption"."EncryptionAlgo" AS ENUM ('x-wing'); +CREATE TYPE "EncryptionAlgo" AS ENUM ('x-wing'); -- CreateEnum -CREATE TYPE "encryption"."VaultItemType" AS ENUM ('identity', 'encryptionKey', 'tofu', 'active'); +CREATE TYPE "VaultItemType" AS ENUM ('identity', 'encryptionKey', 'tofu', 'active'); -- CreateEnum -CREATE TYPE "encryption"."VaultCredentialType" AS ENUM ('primary', 'emergency'); +CREATE TYPE "VaultCredentialType" AS ENUM ('primary', 'emergency'); -- CreateEnum -CREATE TYPE "encryption"."EmergencyAccessStatus" AS ENUM ('invited', 'confirmed', 'recovery_requested', 'recovery_approved'); +CREATE TYPE "EmergencyAccessStatus" AS ENUM ('invited', 'confirmed', 'recovery_requested', 'recovery_approved'); -- CreateTable -CREATE TABLE "encryption"."users" ( +CREATE TABLE "users" ( "id" UUID NOT NULL, "email" TEXT NOT NULL, "language" TEXT NOT NULL DEFAULT 'en', @@ -28,7 +25,7 @@ CREATE TABLE "encryption"."users" ( ); -- CreateTable -CREATE TABLE "encryption"."oidc_accounts" ( +CREATE TABLE "oidc_accounts" ( "id" UUID NOT NULL, "user_id" UUID NOT NULL, "issuer" TEXT NOT NULL, @@ -41,11 +38,11 @@ CREATE TABLE "encryption"."oidc_accounts" ( ); -- CreateTable -CREATE TABLE "encryption"."identities" ( +CREATE TABLE "identities" ( "id" UUID NOT NULL, "user_id" UUID NOT NULL, "signature_public_key" BYTEA NOT NULL, - "algo" "encryption"."SignatureAlgo" NOT NULL DEFAULT 'ed25519', + "algo" "SignatureAlgo" NOT NULL DEFAULT 'ed25519', "generation" INTEGER NOT NULL, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "disabled_at" TIMESTAMP(3), @@ -56,12 +53,12 @@ CREATE TABLE "encryption"."identities" ( ); -- CreateTable -CREATE TABLE "encryption"."encryption_keys" ( +CREATE TABLE "encryption_keys" ( "id" UUID NOT NULL, "user_id" UUID NOT NULL, "identity_id" UUID NOT NULL, "encryption_public_key" BYTEA NOT NULL, - "algo" "encryption"."EncryptionAlgo" NOT NULL DEFAULT 'x-wing', + "algo" "EncryptionAlgo" NOT NULL DEFAULT 'x-wing', "key_binding_signature" BYTEA NOT NULL, "version" INTEGER NOT NULL, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -72,7 +69,7 @@ CREATE TABLE "encryption"."encryption_keys" ( ); -- CreateTable -CREATE TABLE "encryption"."key_possession_challenges" ( +CREATE TABLE "key_possession_challenges" ( "id" UUID NOT NULL, "user_id" UUID NOT NULL, "encryption_public_key" BYTEA NOT NULL, @@ -88,11 +85,11 @@ CREATE TABLE "encryption"."key_possession_challenges" ( ); -- CreateTable -CREATE TABLE "encryption"."vault_items" ( +CREATE TABLE "vault_items" ( "id" UUID NOT NULL, "vault_id" UUID NOT NULL, "item_id" TEXT NOT NULL, - "type" "encryption"."VaultItemType" NOT NULL, + "type" "VaultItemType" NOT NULL, "ciphertext" TEXT NOT NULL, "revision_date" TIMESTAMP(3) NOT NULL, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -102,7 +99,7 @@ CREATE TABLE "encryption"."vault_items" ( ); -- CreateTable -CREATE TABLE "encryption"."vault_meta" ( +CREATE TABLE "vault_meta" ( "vault_id" UUID NOT NULL, "account_revision" INTEGER NOT NULL DEFAULT 0, "manifest" TEXT NOT NULL, @@ -113,7 +110,7 @@ CREATE TABLE "encryption"."vault_meta" ( ); -- CreateTable -CREATE TABLE "encryption"."vault_keyring" ( +CREATE TABLE "vault_keyring" ( "id" UUID NOT NULL, "user_id" UUID NOT NULL, "identity_id" UUID NOT NULL, @@ -125,10 +122,10 @@ CREATE TABLE "encryption"."vault_keyring" ( ); -- CreateTable -CREATE TABLE "encryption"."vault_credentials" ( +CREATE TABLE "vault_credentials" ( "id" UUID NOT NULL, "vault_id" UUID NOT NULL, - "type" "encryption"."VaultCredentialType" NOT NULL, + "type" "VaultCredentialType" NOT NULL, "wrapped_vrk" TEXT NOT NULL, "auth_public_key" BYTEA NOT NULL, "auth_pub_sig" BYTEA NOT NULL, @@ -142,11 +139,11 @@ CREATE TABLE "encryption"."vault_credentials" ( ); -- CreateTable -CREATE TABLE "encryption"."emergency_access" ( +CREATE TABLE "emergency_access" ( "id" UUID NOT NULL, "grantor_user_id" UUID NOT NULL, "grantee_user_id" UUID NOT NULL, - "status" "encryption"."EmergencyAccessStatus" NOT NULL, + "status" "EmergencyAccessStatus" NOT NULL, "wait_time_days" INTEGER NOT NULL, "credential_id" UUID NOT NULL, "wrapped_phrase_for_grantee" TEXT NOT NULL, @@ -163,7 +160,7 @@ CREATE TABLE "encryption"."emergency_access" ( ); -- CreateTable -CREATE TABLE "encryption"."vault_challenges" ( +CREATE TABLE "vault_challenges" ( "id" UUID NOT NULL, "user_id" UUID NOT NULL, "nonce" BYTEA NOT NULL, @@ -174,7 +171,7 @@ CREATE TABLE "encryption"."vault_challenges" ( ); -- CreateTable -CREATE TABLE "encryption"."vault_approvals" ( +CREATE TABLE "vault_approvals" ( "id" UUID NOT NULL, "user_id" UUID NOT NULL, "request_id" TEXT NOT NULL, @@ -187,134 +184,134 @@ CREATE TABLE "encryption"."vault_approvals" ( ); -- CreateIndex -CREATE INDEX "oidc_accounts_user_id_idx" ON "encryption"."oidc_accounts"("user_id"); +CREATE INDEX "oidc_accounts_user_id_idx" ON "oidc_accounts"("user_id"); -- CreateIndex -CREATE UNIQUE INDEX "oidc_accounts_issuer_subject_key" ON "encryption"."oidc_accounts"("issuer", "subject"); +CREATE UNIQUE INDEX "oidc_accounts_issuer_subject_key" ON "oidc_accounts"("issuer", "subject"); -- CreateIndex -CREATE UNIQUE INDEX "identities_signature_public_key_key" ON "encryption"."identities"("signature_public_key"); +CREATE UNIQUE INDEX "identities_signature_public_key_key" ON "identities"("signature_public_key"); -- CreateIndex -CREATE INDEX "identities_user_id_disabled_at_idx" ON "encryption"."identities"("user_id", "disabled_at"); +CREATE INDEX "identities_user_id_disabled_at_idx" ON "identities"("user_id", "disabled_at"); -- CreateIndex -CREATE UNIQUE INDEX "identities_user_id_generation_key" ON "encryption"."identities"("user_id", "generation"); +CREATE UNIQUE INDEX "identities_user_id_generation_key" ON "identities"("user_id", "generation"); -- CreateIndex -CREATE UNIQUE INDEX "encryption_keys_encryption_public_key_key" ON "encryption"."encryption_keys"("encryption_public_key"); +CREATE UNIQUE INDEX "encryption_keys_encryption_public_key_key" ON "encryption_keys"("encryption_public_key"); -- CreateIndex -CREATE INDEX "encryption_keys_user_id_disabled_at_idx" ON "encryption"."encryption_keys"("user_id", "disabled_at"); +CREATE INDEX "encryption_keys_user_id_disabled_at_idx" ON "encryption_keys"("user_id", "disabled_at"); -- CreateIndex -CREATE INDEX "encryption_keys_user_id_created_at_idx" ON "encryption"."encryption_keys"("user_id", "created_at"); +CREATE INDEX "encryption_keys_user_id_created_at_idx" ON "encryption_keys"("user_id", "created_at"); -- CreateIndex -CREATE INDEX "encryption_keys_identity_id_idx" ON "encryption"."encryption_keys"("identity_id"); +CREATE INDEX "encryption_keys_identity_id_idx" ON "encryption_keys"("identity_id"); -- CreateIndex -CREATE UNIQUE INDEX "encryption_keys_user_id_version_key" ON "encryption"."encryption_keys"("user_id", "version"); +CREATE UNIQUE INDEX "encryption_keys_user_id_version_key" ON "encryption_keys"("user_id", "version"); -- CreateIndex -CREATE INDEX "key_possession_challenges_user_id_idx" ON "encryption"."key_possession_challenges"("user_id"); +CREATE INDEX "key_possession_challenges_user_id_idx" ON "key_possession_challenges"("user_id"); -- CreateIndex -CREATE INDEX "key_possession_challenges_expires_at_idx" ON "encryption"."key_possession_challenges"("expires_at"); +CREATE INDEX "key_possession_challenges_expires_at_idx" ON "key_possession_challenges"("expires_at"); -- CreateIndex -CREATE INDEX "vault_items_vault_id_idx" ON "encryption"."vault_items"("vault_id"); +CREATE INDEX "vault_items_vault_id_idx" ON "vault_items"("vault_id"); -- CreateIndex -CREATE UNIQUE INDEX "vault_items_vault_id_item_id_key" ON "encryption"."vault_items"("vault_id", "item_id"); +CREATE UNIQUE INDEX "vault_items_vault_id_item_id_key" ON "vault_items"("vault_id", "item_id"); -- CreateIndex -CREATE INDEX "vault_keyring_user_id_idx" ON "encryption"."vault_keyring"("user_id"); +CREATE INDEX "vault_keyring_user_id_idx" ON "vault_keyring"("user_id"); -- CreateIndex -CREATE INDEX "vault_keyring_user_id_disabled_at_idx" ON "encryption"."vault_keyring"("user_id", "disabled_at"); +CREATE INDEX "vault_keyring_user_id_disabled_at_idx" ON "vault_keyring"("user_id", "disabled_at"); -- CreateIndex -CREATE INDEX "vault_credentials_vault_id_idx" ON "encryption"."vault_credentials"("vault_id"); +CREATE INDEX "vault_credentials_vault_id_idx" ON "vault_credentials"("vault_id"); -- CreateIndex -CREATE UNIQUE INDEX "emergency_access_credential_id_key" ON "encryption"."emergency_access"("credential_id"); +CREATE UNIQUE INDEX "emergency_access_credential_id_key" ON "emergency_access"("credential_id"); -- CreateIndex -CREATE INDEX "emergency_access_grantee_user_id_idx" ON "encryption"."emergency_access"("grantee_user_id"); +CREATE INDEX "emergency_access_grantee_user_id_idx" ON "emergency_access"("grantee_user_id"); -- CreateIndex -CREATE INDEX "emergency_access_grantee_identity_id_idx" ON "encryption"."emergency_access"("grantee_identity_id"); +CREATE INDEX "emergency_access_grantee_identity_id_idx" ON "emergency_access"("grantee_identity_id"); -- CreateIndex -CREATE INDEX "emergency_access_status_recovery_requested_at_idx" ON "encryption"."emergency_access"("status", "recovery_requested_at"); +CREATE INDEX "emergency_access_status_recovery_requested_at_idx" ON "emergency_access"("status", "recovery_requested_at"); -- CreateIndex -CREATE UNIQUE INDEX "emergency_access_grantor_user_id_grantee_user_id_key" ON "encryption"."emergency_access"("grantor_user_id", "grantee_user_id"); +CREATE UNIQUE INDEX "emergency_access_grantor_user_id_grantee_user_id_key" ON "emergency_access"("grantor_user_id", "grantee_user_id"); -- CreateIndex -CREATE INDEX "vault_challenges_user_id_idx" ON "encryption"."vault_challenges"("user_id"); +CREATE INDEX "vault_challenges_user_id_idx" ON "vault_challenges"("user_id"); -- CreateIndex -CREATE INDEX "vault_challenges_expires_at_idx" ON "encryption"."vault_challenges"("expires_at"); +CREATE INDEX "vault_challenges_expires_at_idx" ON "vault_challenges"("expires_at"); -- CreateIndex -CREATE UNIQUE INDEX "vault_approvals_request_id_key" ON "encryption"."vault_approvals"("request_id"); +CREATE UNIQUE INDEX "vault_approvals_request_id_key" ON "vault_approvals"("request_id"); -- CreateIndex -CREATE INDEX "vault_approvals_user_id_idx" ON "encryption"."vault_approvals"("user_id"); +CREATE INDEX "vault_approvals_user_id_idx" ON "vault_approvals"("user_id"); -- CreateIndex -CREATE INDEX "vault_approvals_expires_at_idx" ON "encryption"."vault_approvals"("expires_at"); +CREATE INDEX "vault_approvals_expires_at_idx" ON "vault_approvals"("expires_at"); -- AddForeignKey -ALTER TABLE "encryption"."oidc_accounts" ADD CONSTRAINT "oidc_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "oidc_accounts" ADD CONSTRAINT "oidc_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."identities" ADD CONSTRAINT "identities_previous_identity_id_fkey" FOREIGN KEY ("previous_identity_id") REFERENCES "encryption"."identities"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "identities" ADD CONSTRAINT "identities_previous_identity_id_fkey" FOREIGN KEY ("previous_identity_id") REFERENCES "identities"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."identities" ADD CONSTRAINT "identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "identities" ADD CONSTRAINT "identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."encryption_keys" ADD CONSTRAINT "encryption_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "encryption_keys" ADD CONSTRAINT "encryption_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."encryption_keys" ADD CONSTRAINT "encryption_keys_identity_id_fkey" FOREIGN KEY ("identity_id") REFERENCES "encryption"."identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "encryption_keys" ADD CONSTRAINT "encryption_keys_identity_id_fkey" FOREIGN KEY ("identity_id") REFERENCES "identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."key_possession_challenges" ADD CONSTRAINT "key_possession_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "key_possession_challenges" ADD CONSTRAINT "key_possession_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."vault_items" ADD CONSTRAINT "vault_items_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "encryption"."vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "vault_items" ADD CONSTRAINT "vault_items_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."vault_meta" ADD CONSTRAINT "vault_meta_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "encryption"."vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "vault_meta" ADD CONSTRAINT "vault_meta_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."vault_keyring" ADD CONSTRAINT "vault_keyring_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "vault_keyring" ADD CONSTRAINT "vault_keyring_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."vault_keyring" ADD CONSTRAINT "vault_keyring_identity_id_fkey" FOREIGN KEY ("identity_id") REFERENCES "encryption"."identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "vault_keyring" ADD CONSTRAINT "vault_keyring_identity_id_fkey" FOREIGN KEY ("identity_id") REFERENCES "identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."vault_credentials" ADD CONSTRAINT "vault_credentials_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "encryption"."vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "vault_credentials" ADD CONSTRAINT "vault_credentials_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "vault_keyring"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."emergency_access" ADD CONSTRAINT "emergency_access_grantor_user_id_fkey" FOREIGN KEY ("grantor_user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "emergency_access" ADD CONSTRAINT "emergency_access_grantor_user_id_fkey" FOREIGN KEY ("grantor_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."emergency_access" ADD CONSTRAINT "emergency_access_grantee_user_id_fkey" FOREIGN KEY ("grantee_user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "emergency_access" ADD CONSTRAINT "emergency_access_grantee_user_id_fkey" FOREIGN KEY ("grantee_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."emergency_access" ADD CONSTRAINT "emergency_access_grantee_identity_id_fkey" FOREIGN KEY ("grantee_identity_id") REFERENCES "encryption"."identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "emergency_access" ADD CONSTRAINT "emergency_access_grantee_identity_id_fkey" FOREIGN KEY ("grantee_identity_id") REFERENCES "identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."emergency_access" ADD CONSTRAINT "emergency_access_credential_id_fkey" FOREIGN KEY ("credential_id") REFERENCES "encryption"."vault_credentials"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "emergency_access" ADD CONSTRAINT "emergency_access_credential_id_fkey" FOREIGN KEY ("credential_id") REFERENCES "vault_credentials"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."vault_challenges" ADD CONSTRAINT "vault_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "vault_challenges" ADD CONSTRAINT "vault_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "encryption"."vault_approvals" ADD CONSTRAINT "vault_approvals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "encryption"."users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "vault_approvals" ADD CONSTRAINT "vault_approvals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 98975f1..1f40362 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -6,7 +6,6 @@ generator client { datasource db { provider = "postgresql" - schemas = ["encryption"] } /// Signature (identity) algorithm. A closed set, not a free string: every value @@ -19,8 +18,6 @@ datasource db { /// signed string. enum SignatureAlgo { ed25519 - - @@schema("encryption") } /// Encryption (KEM) algorithm. Same closed-set rules. Unlike the signature algo, @@ -30,8 +27,6 @@ enum SignatureAlgo { /// the identifier `x_wing`. enum EncryptionAlgo { x_wing @map("x-wing") - - @@schema("encryption") } /// The kind of a sealed vault item (routing metadata the CLIENT uses to rebuild @@ -46,8 +41,6 @@ enum VaultItemType { encryptionKey tofu active - - @@schema("encryption") } /// A user of the encryption service. `id` is the canonical, immutable user @@ -73,7 +66,6 @@ model User { emergencyAccessReceived EmergencyAccess[] @relation("emergencyAccessAsGrantee") @@map("users") - @@schema("encryption") } /// One OIDC credential (issuer + sub) linked to a user. `subject` is only @@ -98,7 +90,6 @@ model OidcAccount { @@unique([issuer, subject]) @@index([userId]) @@map("oidc_accounts") - @@schema("encryption") } /// A user's IDENTITY: the Ed25519 signature key contacts verify out-of-band @@ -146,7 +137,6 @@ model Identity { @@unique([userId, generation]) @@index([userId, disabledAt]) @@map("identities") - @@schema("encryption") } /// One encryption public key (X-Wing wire blob) bound to an Identity for one @@ -184,7 +174,6 @@ model EncryptionKey { @@index([userId, createdAt]) @@index([identityId]) @@map("encryption_keys") - @@schema("encryption") } /// Pending proof-of-possession challenges for identity registration. @@ -215,7 +204,6 @@ model KeyPossessionChallenge { @@index([userId]) @@index([expiresAt]) @@map("key_possession_challenges") - @@schema("encryption") } /// One opaque record of a user's synchronized vault (an encryption key version, @@ -238,7 +226,6 @@ model VaultItem { @@unique([vaultId, itemId]) @@index([vaultId]) @@map("vault_items") - @@schema("encryption") } /// Per-vault metadata: the monotonic account revision (what clients poll to @@ -261,7 +248,6 @@ model VaultMeta { vault VaultKeyring @relation(fields: [vaultId], references: [id], onDelete: Cascade) @@map("vault_meta") - @@schema("encryption") } /// A user's vault (container). A user can hold SEVERAL keyrings over time: @@ -289,7 +275,6 @@ model VaultKeyring { @@index([userId]) @@index([userId, disabledAt]) @@map("vault_keyring") - @@schema("encryption") } /// The kind of a vault credential. `primary` is the owner's own recovery phrase @@ -299,8 +284,6 @@ model VaultKeyring { enum VaultCredentialType { primary emergency - - @@schema("encryption") } /// One way to unlock a vault (a keyslot, LUKS-style): the material derived from @@ -332,7 +315,6 @@ model VaultCredential { @@index([vaultId]) @@map("vault_credentials") - @@schema("encryption") } /// Lifecycle of a trusted-contact relationship @@ -341,8 +323,6 @@ enum EmergencyAccessStatus { confirmed recoveryRequested @map("recovery_requested") recoveryApproved @map("recovery_approved") - - @@schema("encryption") } /// A trusted-contact (emergency access) relationship. Created in ONE step by @@ -383,7 +363,6 @@ model EmergencyAccess { @@index([granteeIdentityId]) @@index([status, recoveryRequestedAt]) @@map("emergency_access") - @@schema("encryption") } /// Single-use nonce issued before a vault fetch or write, signed by the caller's @@ -399,7 +378,6 @@ model VaultChallenge { @@index([userId]) @@index([expiresAt]) @@map("vault_challenges") - @@schema("encryption") } /// A device-approval request. A new device publishes its ephemeral public key @@ -421,5 +399,4 @@ model VaultApproval { @@index([userId]) @@index([expiresAt]) @@map("vault_approvals") - @@schema("encryption") } diff --git a/src/prisma/testing.ts b/src/prisma/testing.ts index 59d31fd..12810b2 100644 --- a/src/prisma/testing.ts +++ b/src/prisma/testing.ts @@ -67,8 +67,8 @@ async function start(): Promise { db = await PGlite.create({ loadDataDir: new Blob([readFileSync(TEST_DATABASE_SNAPSHOT_PATH)]) }); client = new PrismaClient({ adapter: new PrismaPGlite(db) } as never); - const tables = await client.$queryRaw<{ tablename: string }[]>`SELECT tablename FROM pg_tables WHERE schemaname = 'encryption'`; - truncateStatement = `TRUNCATE TABLE ${tables.map((row) => `"encryption"."${row.tablename}"`).join(', ')} CASCADE`; + const tables = await client.$queryRaw<{ tablename: string }[]>`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`; + truncateStatement = `TRUNCATE TABLE ${tables.map((row) => `"${row.tablename}"`).join(', ')} CASCADE`; } async function stop(): Promise { diff --git a/src/server/email/layout.tsx b/src/server/email/layout.tsx index 850428b..a649b06 100644 --- a/src/server/email/layout.tsx +++ b/src/server/email/layout.tsx @@ -71,7 +71,7 @@ export function StandardLayout(props: PropsWithChildren) { )} - + diff --git a/src/server/email/styles.ts b/src/server/email/styles.ts index faf8a34..b0ba248 100644 --- a/src/server/email/styles.ts +++ b/src/server/email/styles.ts @@ -9,7 +9,10 @@ import { emailPalette as c } from '@encryption/src/server/email/palette'; // The light backgrounds live on the elements themselves (inline mj-attributes), so // they render in the Storybook preview too (which injects the email HTML into a //
, where a bare `body {}` rule would target the page, not the email). The -// dark overrides below use `!important` to beat those inline values. +// dark overrides below use `!important` to beat those inline values. The body's +// own background (what a client shows below a short email) is lost in that +// injection, so the Storybook stylesheet paints the page with it on purpose: +// there, the page stands for the client's viewport. // The palette can be overridden at startup (EMAIL_PALETTE_PATH), so the CSS is // built lazily on each render rather than frozen into a module-load const, which @@ -111,6 +114,10 @@ export function getStorybookStyles(): string { return ` ${baseStyles()} +body.sb-show-main { + background: ${c.lightBody} !important; +} + body.sb-show-main.dark, body.sb-show-main.dark .body-wrapper { background: ${c.darkBody} !important; diff --git a/src/server/plugins/security-headers.test.ts b/src/server/plugins/security-headers.test.ts index 9fffe32..6173d60 100644 --- a/src/server/plugins/security-headers.test.ts +++ b/src/server/plugins/security-headers.test.ts @@ -156,6 +156,19 @@ describe('securityHeadersPlugin', () => { expect((await headersFor('/login'))['cross-origin-resource-policy']).toBe('same-site'); }); + it('lets a product load the client SDK from its own origin with a plain script tag', async () => { + const app = await buildApp(); + const headersFor = async (url: string, host = VAULT_HOST) => (await app.inject({ method: 'GET', url, headers: { host } })).headers; + + for (const url of ['/client.js', '/client.mjs', '/client.d.ts', '/client.js?v=1']) { + expect((await headersFor(url))['cross-origin-resource-policy']).toBe('cross-origin'); + expect((await headersFor(url))['content-security-policy']).toBeUndefined(); + } + // The vault bundle itself, and the same paths on any other host, stay locked. + expect((await headersFor('/vault.js'))['cross-origin-resource-policy']).toBe('same-origin'); + expect((await headersFor('/client.js', UI_HOST))['cross-origin-resource-policy']).toBe('same-site'); + }); + it('keeps COOP on the vault, which opens no popup', async () => { expect((await headersFor(VAULT_HOST))['cross-origin-opener-policy']).toBe('same-origin'); }); diff --git a/src/server/plugins/security-headers.ts b/src/server/plugins/security-headers.ts index 6f71b5e..32858bc 100644 --- a/src/server/plugins/security-headers.ts +++ b/src/server/plugins/security-headers.ts @@ -18,6 +18,9 @@ import { BROWSER_REPORT_PATH, UI_TRUSTED_TYPES_POLICY, VAULT_TRUSTED_TYPES_POLIC // merges headers set with `setHeader()` into the later `writeHead()` Fastify does for // its own replies. This plugin must therefore stay registered BEFORE the Vite plugin, // since Fastify runs onRequest hooks in registration order. +/** The client SDK as products load it, served by the vault host. */ +const SDK_PATHS = new Set(['/client.js', '/client.mjs', '/client.d.ts']); + export const securityHeadersPlugin = fp(async (app: FastifyInstance): Promise => { const isDev = process.env.NODE_ENV === 'development'; const productFrameAncestors = env.ALLOWED_FRAME_ANCESTORS.split(',') @@ -30,11 +33,16 @@ export const securityHeadersPlugin = fp(async (app: FastifyInstance): Promise { - // Public assets are meant to be embedded cross-origin — the logo in a mail - // client, the fonts in a generated PDF — so they get a relaxed CORP and skip - // the iframe-only CSP. This is the ONLY exception; everything else below stays - // same-site / same-origin. - if (request.url.startsWith('/public-assets/')) { + const path = request.url.split('?')[0]; + + // Two kinds of files are meant to be fetched from another origin, so they get + // a relaxed CORP and skip the iframe-only CSP: the public assets (the logo in + // a mail client, the fonts in a generated PDF) and the client SDK, which a + // product loads with a plain