From f00198fe94176524a2d0ce71b2f06d9c795a4afe Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 06:36:56 +0200 Subject: [PATCH 01/16] docs(credentials): define Postgres inventory boundaries --- README.md | 14 +-- deploy/credential-inventory.json | 81 ++++++++++++++++ docs/credential-sync.md | 152 +++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 deploy/credential-inventory.json create mode 100644 docs/credential-sync.md diff --git a/README.md b/README.md index d8086e3..08bd4b6 100644 --- a/README.md +++ b/README.md @@ -236,13 +236,13 @@ GitHub environment variables. The exact Brio inventory is: | --- | --- | --- | | `Hetzner Database Server makepad` | `canary`, `production`, `staging-brio-identity-db`, and `keycloak-cohort-restore` | canonical SSH fields `DEPLOY_SSH_HOST`, `DEPLOY_SSH_PORT`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_PRIVATE_KEY`, `DEPLOY_SSH_KNOWN_HOSTS`; mirror the same reviewed values under the workflow aliases `BRIO_IDENTITY_DB_DEPLOY_SSH_HOST`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PORT`, `BRIO_IDENTITY_DB_DEPLOY_SSH_USER`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY`, `BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS`, `KEYCLOAK_COHORT_DB_SSH_HOST`, `KEYCLOAK_COHORT_DB_SSH_PORT`, `KEYCLOAK_COHORT_DB_SSH_USER`, `KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY`, and `KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS` only in their named environments | | `Brio Staging - PostgreSQL` | `canary` and `staging-brio-identity-db` | secrets `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, `BRIO_STAGING_BACKUP_DB_PASSWORD`, `KEYCLOAK_BRIO_STAGING_DB_PASSWORD`, and `KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD` | -| `Brio Staging - PKI and Backup Keys` | `canary` and `staging-brio-identity-db` | secrets `POSTGRES_CA_PEM`, `POSTGRES_SERVER_CERT_PEM`, `POSTGRES_SERVER_KEY_PEM`, and public recipient certificate `BRIO_BACKUP_RECIPIENT_CERT_PEM` | +| `Brio Staging - PKI and Backup Keys` | `canary`; recipient certificate only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CA_PEM`, `POSTGRES_SERVER_CERT_PEM`, `POSTGRES_SERVER_KEY_PEM`, and `BRIO_BACKUP_RECIPIENT_CERT_PEM`; only `BRIO_BACKUP_RECIPIENT_CERT_PEM` is mirrored to `staging-brio-identity-db` | | `PostgreSQL · Brio identity release orchestrator` | `release-brio-identity-db` | secret `KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN` | | `PostgreSQL · Keycloak cohort source reader` | `keycloak-cohort-restore` | secret `KEYCLOAK_COHORT_SOURCE_TOKEN` | | `Makepad Docker Hardened Images` | `keycloak-cohort-restore` | canonical fields `DOCKERHUB_USERNAME` and `DOCKERHUB_PRO_PAT`, mirrored as secrets `DHI_REGISTRY_USERNAME` and `DHI_REGISTRY_PASSWORD` | -| `PostgreSQL · PR Checks App` | `postgres-ci-attestation` | variable `POSTGRES_PR_CHECK_APP_ID` and secret `POSTGRES_PR_CHECK_APP_PRIVATE_KEY` | -| `PostgreSQL · JIT Launcher App` | `postgres-ci-attestation` | public variable `POSTGRES_CI_LAUNCHER_APP_SENDER_ID`; private App fields remain on the controller host only | -| `PostgreSQL · JIT hypervisor attestation` | `postgres-ci-attestation` | public variables `POSTGRES_CI_ATTESTATION_PUBLIC_KEY` and `POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256`; the signing key remains on the hypervisor only | +| `PostgreSQL · PR Checks App` | `postgres-ci-attestation` and repository policy | secret `POSTGRES_PR_CHECK_APP_PRIVATE_KEY` in the environment; public repository variable `POSTGRES_PR_CHECK_APP_ID` | +| `PostgreSQL · JIT Launcher App` | repository policy only | public repository variable `POSTGRES_CI_LAUNCHER_APP_SENDER_ID`; private App fields remain on the controller host only | +| `PostgreSQL · JIT hypervisor attestation` | repository policy only | public repository variables `POSTGRES_CI_ATTESTATION_PUBLIC_KEY` and `POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256`; the signing key remains on the hypervisor only | The `canary`, `production`, `staging-brio-identity-db`, and `keycloak-cohort-restore` environments also hold reviewed non-secret constants @@ -250,8 +250,10 @@ such as `DEPLOY_REMOTE_DIR`, stack/network names, `BRIO_IDENTITY_DB_HOSTNAME`, and `BRIO_KEYCLOAK_DB_SOURCE_CIDR`. Store them as environment variables, not duplicated password-vault secrets. -Use `pass-cli` from an approved administrator workstation and stream secret -values over standard input: +The machine-readable mapping and fail-closed operator procedure are documented +in [`docs/credential-sync.md`](docs/credential-sync.md). Use `pass-cli` from an +approved administrator workstation and stream secret values over standard +input: ```bash pass-cli item view --item-title '' --field '' \ diff --git a/deploy/credential-inventory.json b/deploy/credential-inventory.json new file mode 100644 index 0000000..f155f85 --- /dev/null +++ b/deploy/credential-inventory.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": 1, + "repository": "Makepad-fr/postgres", + "vault": "Makepad", + "githubEntries": [ + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_REMOTE_DIR","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_REMOTE_DIR"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_STACK_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_STACK_NAME"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_CATWLK_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_CATWLK_DB_NETWORK"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_LE_PETIT_COIN_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_LE_PETIT_COIN_DB_NETWORK"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_BRIO_STAGING_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_BRIO_STAGING_DB_NETWORK"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"POSTGRES_CANARY_SUPERUSER_PASSWORD","item":"Brio Staging - PostgreSQL","field":"POSTGRES_CANARY_SUPERUSER_PASSWORD"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"BRIO_STAGING_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"BRIO_STAGING_DB_PASSWORD"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"BRIO_STAGING_BACKUP_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"BRIO_STAGING_BACKUP_DB_PASSWORD"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"POSTGRES_CA_PEM","item":"Brio Staging - PKI and Backup Keys","field":"POSTGRES_CA_PEM"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"POSTGRES_SERVER_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"POSTGRES_SERVER_CERT_PEM"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"POSTGRES_SERVER_KEY_PEM","item":"Brio Staging - PKI and Backup Keys","field":"POSTGRES_SERVER_KEY_PEM"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"BRIO_BACKUP_RECIPIENT_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"BRIO_BACKUP_RECIPIENT_CERT_PEM"}, + + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_REMOTE_DIR","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_REMOTE_DIR"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_STACK_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_STACK_NAME"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_CATWLK_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_CATWLK_DB_NETWORK"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_LE_PETIT_COIN_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_LE_PETIT_COIN_DB_NETWORK"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_NETWORK"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_NAME"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_USER","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_USER"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_PASSWORD","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_PASSWORD"}, + + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_BRIO_STAGING_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"KEYCLOAK_BRIO_STAGING_DB_PASSWORD"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_BACKUP_RECIPIENT_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"BRIO_BACKUP_RECIPIENT_CERT_PEM"}, + {"environment":"staging-brio-identity-db","kind":"variable","requirement":"required","destination":"BRIO_IDENTITY_DB_HOSTNAME","item":"Brio Staging - PKI and Backup Keys","field":"BRIO_IDENTITY_DB_HOSTNAME"}, + {"environment":"staging-brio-identity-db","kind":"variable","requirement":"required","destination":"BRIO_KEYCLOAK_DB_SOURCE_CIDR","item":"Brio Staging - PKI and Backup Keys","field":"BRIO_KEYCLOAK_DB_SOURCE_CIDR"}, + + {"environment":"release-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN","item":"PostgreSQL · Brio identity release orchestrator","field":"KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN"}, + + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_SOURCE_TOKEN","item":"PostgreSQL · Keycloak cohort source reader","field":"KEYCLOAK_COHORT_SOURCE_TOKEN"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"DHI_REGISTRY_USERNAME","item":"Makepad Docker Hardened Images","field":"DOCKERHUB_USERNAME"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"DHI_REGISTRY_PASSWORD","item":"Makepad Docker Hardened Images","field":"DOCKERHUB_PRO_PAT"}, + + {"environment":"postgres-ci-attestation","kind":"secret","requirement":"required","destination":"POSTGRES_PR_CHECK_APP_PRIVATE_KEY","item":"PostgreSQL · PR Checks App","field":"private_key"} + ], + "repositoryVariables": [ + {"requirement":"required","destination":"POSTGRES_CI_LAUNCHER_APP_SENDER_ID","item":"PostgreSQL · JIT Launcher App","field":"bot_user_id"}, + {"requirement":"required","destination":"POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256","item":"PostgreSQL · JIT hypervisor attestation","field":"qcow2_sha256"}, + {"requirement":"required","destination":"POSTGRES_CI_ATTESTATION_PUBLIC_KEY","item":"PostgreSQL · JIT hypervisor attestation","field":"ed25519_public_key"}, + {"requirement":"required","destination":"POSTGRES_PR_CHECK_APP_ID","item":"PostgreSQL · PR Checks App","field":"app_id"} + ], + "nonGitHubEntries": [ + {"boundary":"operator-verification","requirement":"required","destination":"PostgreSQL Checks App private-key fingerprint","item":"PostgreSQL · PR Checks App","field":"private_key_fingerprint"}, + {"boundary":"host-root-setting","requirement":"required","destination":"/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_LAUNCHER_APP_ID","item":"PostgreSQL · JIT Launcher App","field":"app_id"}, + {"boundary":"host-root-setting","requirement":"required","destination":"/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_LAUNCHER_APP_INSTALLATION_ID","item":"PostgreSQL · JIT Launcher App","field":"installation_id"}, + {"boundary":"host-root-file","requirement":"required","destination":"/etc/makepad/postgres-ci/launcher-app-private-key.pem","item":"PostgreSQL · JIT Launcher App","field":"private_key"}, + {"boundary":"operator-verification","requirement":"required","destination":"PostgreSQL Launcher App private-key fingerprint","item":"PostgreSQL · JIT Launcher App","field":"private_key_fingerprint"}, + {"boundary":"host-root-setting","requirement":"required","destination":"/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_REPOSITORY_ID","item":"PostgreSQL · JIT Launcher App","field":"repository_id"}, + {"boundary":"host-root-file","requirement":"required","destination":"/etc/makepad/postgres-ci/attestation-private-key.pem","item":"PostgreSQL · JIT hypervisor attestation","field":"ed25519_private_key"}, + {"boundary":"operator-verification","requirement":"required","destination":"PostgreSQL Ed25519 public-key fingerprint","item":"PostgreSQL · JIT hypervisor attestation","field":"public_key_fingerprint"}, + {"boundary":"host-root-setting","requirement":"required","destination":"/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_BASE_IMAGE_SHA256","item":"PostgreSQL · JIT hypervisor attestation","field":"qcow2_sha256"}, + {"boundary":"operator-stdin","requirement":"required","destination":"scripts/configure-postgres-ci-runner-group.sh standard input","item":"PostgreSQL · runner-group controller","field":"organization_runner_admin_token"}, + {"boundary":"host-root-file","requirement":"required","destination":"POSTGRES_HOST_ALERT_URL_FILE","item":"PostgreSQL · CI hypervisor alert","field":"url"} + ] +} diff --git a/docs/credential-sync.md b/docs/credential-sync.md new file mode 100644 index 0000000..863c3a2 --- /dev/null +++ b/docs/credential-sync.md @@ -0,0 +1,152 @@ +# PostgreSQL credential inventory and GitHub sync + +`deploy/credential-inventory.json` is the reviewed mapping from the shared +Proton Pass vault `Makepad` to `Makepad-fr/postgres`. It covers the six +protected GitHub environments, the four public repository policy variables, +and the root/operator boundaries used by the disposable-runner control plane. +Repository code never creates, rotates, or deletes a credential. + +Run the non-mutating audit first: + +```sh +./scripts/sync-github-environments.sh --check +./scripts/sync-github-environments.sh --check --environment canary +``` + +Check mode reads active Proton item **titles only** with `pass-cli item list`. +It never calls `pass-cli item view`, so it never materializes a credential +field. From GitHub it reads only repository metadata, environment protection, +and secret/variable names. A whole-inventory check lists the root/operator +destinations as `operator-managed`; it does not inspect those systems. + +After resolving every reported missing or unmanaged name, sync exactly one +environment: + +```sh +./scripts/sync-github-environments.sh --sync --environment canary +./scripts/sync-github-environments.sh --sync --environment production +./scripts/sync-github-environments.sh --sync --environment staging-brio-identity-db +./scripts/sync-github-environments.sh --sync --environment release-brio-identity-db +./scripts/sync-github-environments.sh --sync --environment keycloak-cohort-restore +./scripts/sync-github-environments.sh --sync --environment postgres-ci-attestation +``` + +Sync mode rejects an omitted or arbitrary environment. Before its first field +read it proves the repository is private, non-forkable, and uses `main` as its +default branch; proves the selected environment has exactly one custom branch +policy named and typed `main`; rejects every repository-level secret; and +rejects unlisted environment or repository names. It then reads every selected +Proton field before the first GitHub write, rechecks provider names and policy, +and streams each value to `gh secret set` or `gh variable set` over standard +input. Values exist briefly only in process memory: tracing/debug output and +core dumps are disabled, and values never enter arguments, exported child +environments, logs, or files. + +The helper never creates an environment, changes a branch policy, modifies +Proton Pass, sets repository-level values, or deletes a GitHub name. A legacy +name must be removed manually only after its consumer has migrated and the +approved replacement has been read back. Exit `0` means the reviewed names and +protection are complete, exit `1` means a required source/destination or policy +is incomplete, and exit `2` means an unlisted GitHub name remains. + +## Protected environment mirrors + +Every arrow below means `Proton item/field -> GitHub destination`. Fields +consumed as `secrets.*` stay environment secrets; non-confidential hostnames +and CIDRs consumed as `vars.*` are environment variables. + +### `canary` + +- `Hetzner Database Server makepad/DEPLOY_SSH_*` maps to the five exact + `DEPLOY_SSH_*` secrets. +- `PostgreSQL · shared Swarm deployment` maps the remote directory, stack, and + three network fields used by the canary workflow. +- `Brio Staging - PostgreSQL` maps only + `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, and + `BRIO_STAGING_BACKUP_DB_PASSWORD`. +- `Brio Staging - PKI and Backup Keys` maps `POSTGRES_CA_PEM`, + `POSTGRES_SERVER_CERT_PEM`, `POSTGRES_SERVER_KEY_PEM`, and + `BRIO_BACKUP_RECIPIENT_CERT_PEM`. + +The two Keycloak database passwords are not canary inputs. If old copies remain +there, the helper reports them as unmanaged instead of silently retaining or +deleting them. + +### `production` + +- `Hetzner Database Server makepad/DEPLOY_SSH_*` maps to the five exact SSH + secrets. +- `PostgreSQL · shared Swarm deployment` maps the remote directory, stack, + Catwlk/Le Petit Coin/VIF networks, VIF database and role names, and the VIF + password consumed by the workflow. + +Historical Fashion or Scraping fields are not consumed by the current +workflow and are deliberately absent from the reviewed inventory. + +### `staging-brio-identity-db` + +- The five canonical `DEPLOY_SSH_*` fields map to their + `BRIO_IDENTITY_DB_DEPLOY_SSH_*` aliases. +- `Brio Staging - PostgreSQL` maps only the Keycloak application and backup + database passwords. +- `Brio Staging - PKI and Backup Keys` maps the public recovery recipient + certificate as an environment secret, matching the workflow, plus + `BRIO_IDENTITY_DB_HOSTNAME` and `BRIO_KEYCLOAK_DB_SOURCE_CIDR` as variables. + +The CA, PostgreSQL server certificate, and PostgreSQL private key are used only +by `canary`; they must never be copied into this DB-host deployment environment. +The recovery recipient certificate is needed by both workflows and is the only +PKI/backup-certificate field mirrored here. + +### Release, cohort restore, and CI attestation + +- `release-brio-identity-db` receives only the dedicated + `KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN` secret. +- `keycloak-cohort-restore` receives its read-only Keycloak source token, the + five DB-capture SSH aliases, and the DHI pull username/token. +- `postgres-ci-attestation` receives only the Checks App private key. The + Launcher App private key and the Ed25519 signing key never enter Actions. + +## Public repository variables and forbidden repository secrets + +The job-level dispatch guard must read +`POSTGRES_CI_LAUNCHER_APP_SENDER_ID` before GitHub exposes an environment, so +that immutable bot ID is a repository variable. The related +`POSTGRES_PR_CHECK_APP_ID`, `POSTGRES_CI_ATTESTATION_PUBLIC_KEY`, and +`POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256` are also public policy inputs and +remain repository variables. They are identifiers, a public key, and a digest, +not credentials. The generic helper audits their exact names and canonical +Proton item titles but does not overwrite repository variables. + +Repository-level secrets are forbidden. Their exposure boundary would include +workflows that have not passed a protected environment gate. Any repository +secret name blocks both check and sync; the helper never reads or deletes it. + +## Proton-only and root-only destinations + +The inventory records these separately so they cannot be confused with an +Actions mirror: + +- Launcher App ID and installation ID are root-only `controller.env` settings; + its private key is `/etc/makepad/postgres-ci/launcher-app-private-key.pem` + mode `0400`. GitHub receives only the bot user ID as a public variable. +- The Ed25519 private key is + `/etc/makepad/postgres-ci/attestation-private-key.pem` mode `0400`. GitHub + receives only its public key. The reviewed qcow2 digest is present both in + root-only controller configuration and as a public policy variable. +- The repository numeric ID is root-only controller configuration. +- The runner-group administration token is streamed only to + `scripts/configure-postgres-ci-runner-group.sh` on an administrator + workstation; it is never installed on a runner or hypervisor. +- The host alert URL remains a root-only file consumed by the systemd failure + handler and is never mirrored to Actions. + +Install a private key from `pass-cli` directly into a newly created owner-only +file using a trusted root process. Do not use a command-line argument, dotenv +file, runner workspace, shell history, clipboard, or GitHub secret as an +intermediate. Compare the non-secret fingerprint recorded in Proton after +installation. + +Ephemeral `GITHUB_TOKEN`, GitHub App installation tokens, runner registration +tokens, and disposable database passwords are intentionally absent: they are +minted per operation and never retained. From c8a2007d7b4a88d81dea79efbba9c32a66d03900 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 06:43:14 +0200 Subject: [PATCH 02/16] feat(credentials): add fail-closed environment sync --- scripts/sync-github-environments.sh | 589 ++++++++++++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100755 scripts/sync-github-environments.sh diff --git a/scripts/sync-github-environments.sh b/scripts/sync-github-environments.sh new file mode 100755 index 0000000..92e78a8 --- /dev/null +++ b/scripts/sync-github-environments.sh @@ -0,0 +1,589 @@ +#!/usr/bin/env bash + +# Never inherit tracing while values are held in memory. Selected values are +# sent to gh only on standard input and are never exported or written to disk. +set +x +set -Eeuo pipefail +umask 077 +IFS=$' \t\n' +export LANG=C +export LC_ALL=C +unset GH_DEBUG DEBUG PASS_CLI_DEBUG BASH_XTRACEFD + +readonly repository=Makepad-fr/postgres +readonly vault=Makepad +readonly allowed_environments='canary production staging-brio-identity-db release-brio-identity-db keycloak-cohort-restore postgres-ci-attestation' +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +readonly repo_root +readonly inventory=${repo_root}/deploy/credential-inventory.json +readonly max_value_bytes=49152 + +usage() { + printf '%s\n' \ + 'usage: sync-github-environments.sh [--check|--sync] [--environment NAME]' \ + '' \ + ' --check Read names and policy only; never read Proton field values (default).' \ + ' --sync Preflight every selected field, then stream one environment to GitHub.' +} + +die() { + printf 'credential sync: %s\n' "$*" >&2 + exit 1 +} + +mode=check +mode_selected=0 +selected_environment= +while (( $# > 0 )); do + case "$1" in + --check|--sync) + (( mode_selected == 0 )) || die 'select exactly one mode' + mode=${1#--} + mode_selected=1 + ;; + --environment) + (( $# >= 2 )) || die '--environment requires a value' + [[ -z "${selected_environment}" ]] || die '--environment may be supplied only once' + selected_environment=$2 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage >&2 + die "unsupported argument: $1" + ;; + esac + shift +done + +case "${selected_environment}" in + ''|canary|production|staging-brio-identity-db|release-brio-identity-db|keycloak-cohort-restore|postgres-ci-attestation) ;; + *) die 'environment is not in the immutable PostgreSQL inventory' ;; +esac +if [[ "${mode}" == sync && -z "${selected_environment}" ]]; then + die '--sync requires one explicit --environment to bound the write scope' +fi + +for command_name in pass-cli gh jq python3 sort grep awk mktemp find wc tr; do + command -v "${command_name}" >/dev/null || die "${command_name} is required" +done +[[ -f "${inventory}" && ! -L "${inventory}" ]] || die 'credential inventory is missing or is a symbolic link' + +tmp_base=${TMPDIR:-/tmp} +[[ -d "${tmp_base}" && ! -L "${tmp_base}" ]] || die 'temporary directory base is unsafe' +tmp_base=$(cd "${tmp_base}" && pwd -P) +readonly tmp_base +status_root=$(mktemp -d "${tmp_base}/postgres-credential-sync.XXXXXXXX") +[[ -d "${status_root}" && ! -L "${status_root}" ]] || die 'could not create a private status directory' +chmod 0700 "${status_root}" +readonly status_root +readonly github_entries_file=${status_root}/github-entries.tsv +readonly repository_entries_file=${status_root}/repository-entries.tsv +readonly non_github_entries_file=${status_root}/non-github-entries.tsv +readonly selected_sources_file=${status_root}/selected-sources.tsv +readonly proton_items_file=${status_root}/proton-items.txt +readonly repository_secrets_file=${status_root}/github-repository-secrets.txt +readonly repository_variables_file=${status_root}/github-repository-variables.txt + +declare -a entry_environment=() +declare -a entry_kind=() +declare -a entry_requirement=() +declare -a entry_destination=() +declare -a entry_item=() +declare -a entry_field=() +declare -a source_values=() +declare -a source_available=() + +cleanup() { + local index + for index in "${!source_values[@]}"; do + unset 'source_values[index]' + done + if [[ -n "${status_root:-}" && "${status_root}" == "${tmp_base}/postgres-credential-sync."* && -d "${status_root}" && ! -L "${status_root}" ]]; then + find "${status_root}" -depth -mindepth 1 -delete + rmdir -- "${status_root}" + fi +} +trap cleanup EXIT +trap 'exit 130' HUP INT TERM + +python3 - "${inventory}" "${repository}" "${vault}" "${selected_environment}" \ + "${github_entries_file}" "${repository_entries_file}" "${non_github_entries_file}" "${selected_sources_file}" <<'PY' +import json +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +expected_repository = sys.argv[2] +expected_vault = sys.argv[3] +selected_environment = sys.argv[4] +github_output = pathlib.Path(sys.argv[5]) +repository_output = pathlib.Path(sys.argv[6]) +non_github_output = pathlib.Path(sys.argv[7]) +sources_output = pathlib.Path(sys.argv[8]) +payload = json.loads(path.read_text(encoding="utf-8")) + +expected_top_level = { + "schemaVersion", "repository", "vault", "githubEntries", + "repositoryVariables", "nonGitHubEntries", +} +if set(payload) != expected_top_level: + raise SystemExit("credential inventory has unexpected top-level keys") +if payload["schemaVersion"] != 1: + raise SystemExit("unsupported credential inventory schema") +if payload["repository"] != expected_repository or payload["vault"] != expected_vault: + raise SystemExit("credential inventory targets an unexpected repository or vault") + +allowed_environments = { + "canary", "production", "staging-brio-identity-db", + "release-brio-identity-db", "keycloak-cohort-restore", + "postgres-ci-attestation", +} +allowed_kinds = {"secret", "variable"} +allowed_requirements = {"required", "optional"} +allowed_boundaries = { + "host-root-file", "host-root-setting", "operator-stdin", + "operator-verification", +} +public_environment_destinations = { + "BRIO_IDENTITY_DB_HOSTNAME", "BRIO_KEYCLOAK_DB_SOURCE_CIDR", +} +expected_repository_variables = { + "POSTGRES_CI_LAUNCHER_APP_SENDER_ID", + "POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256", + "POSTGRES_CI_ATTESTATION_PUBLIC_KEY", + "POSTGRES_PR_CHECK_APP_ID", +} +destination_pattern = re.compile(r"^[A-Z][A-Z0-9_]{1,127}$") +field_pattern = re.compile(r"^[A-Za-z][A-Za-z0-9_ -]{0,127}$") + +def valid_text(value, limit=256): + return ( + isinstance(value, str) and 0 < len(value) <= limit + and not any(character in value for character in "\t\r\n") + ) + +github_entries = payload["githubEntries"] +repository_entries = payload["repositoryVariables"] +non_github_entries = payload["nonGitHubEntries"] +if not all(isinstance(entries, list) and entries for entries in (github_entries, repository_entries, non_github_entries)): + raise SystemExit("every credential inventory section must be a non-empty list") + +github_lines = [] +repository_lines = [] +non_github_lines = [] +selected_source_requirements = {} +seen_github = set() +seen_repository = set() +seen_non_github = set() +environment_counts = {environment: 0 for environment in allowed_environments} +pki_destinations = set() + +for offset, entry in enumerate(github_entries): + expected_keys = {"environment", "kind", "requirement", "destination", "item", "field"} + if not isinstance(entry, dict) or set(entry) != expected_keys: + raise SystemExit(f"GitHub inventory entry {offset} has unexpected keys") + environment = entry["environment"] + kind = entry["kind"] + requirement = entry["requirement"] + destination = entry["destination"] + item = entry["item"] + field = entry["field"] + if environment not in allowed_environments or kind not in allowed_kinds or requirement not in allowed_requirements: + raise SystemExit(f"GitHub inventory entry {offset} has an invalid classification") + if not isinstance(destination, str) or not destination_pattern.fullmatch(destination): + raise SystemExit(f"GitHub inventory entry {offset} has an invalid destination") + if not valid_text(item, 128) or not isinstance(field, str) or not field_pattern.fullmatch(field): + raise SystemExit(f"GitHub inventory entry {offset} has an invalid Proton source") + if (destination in public_environment_destinations) != (kind == "variable"): + raise SystemExit(f"GitHub inventory entry {offset} has the wrong public/secret classification") + identity = (environment, kind, destination) + if identity in seen_github: + raise SystemExit(f"duplicate GitHub destination: {environment}/{kind}/{destination}") + seen_github.add(identity) + environment_counts[environment] += 1 + if item == "Brio Staging - PKI and Backup Keys" and destination.endswith("_PEM"): + pki_destinations.add((environment, destination)) + if not selected_environment or environment == selected_environment: + github_lines.append("\t".join((environment, kind, requirement, destination, item, field))) + prior = selected_source_requirements.get(item) + selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" + +if any(count == 0 for count in environment_counts.values()): + raise SystemExit("every approved GitHub environment must have at least one inventory entry") +expected_pki_destinations = { + ("canary", "POSTGRES_CA_PEM"), + ("canary", "POSTGRES_SERVER_CERT_PEM"), + ("canary", "POSTGRES_SERVER_KEY_PEM"), + ("canary", "BRIO_BACKUP_RECIPIENT_CERT_PEM"), + ("staging-brio-identity-db", "BRIO_BACKUP_RECIPIENT_CERT_PEM"), +} +if pki_destinations != expected_pki_destinations: + raise SystemExit("Brio PKI destinations do not match the reviewed workflow split") + +for offset, entry in enumerate(repository_entries): + expected_keys = {"requirement", "destination", "item", "field"} + if not isinstance(entry, dict) or set(entry) != expected_keys: + raise SystemExit(f"repository-variable entry {offset} has unexpected keys") + requirement = entry["requirement"] + destination = entry["destination"] + item = entry["item"] + field = entry["field"] + if requirement not in allowed_requirements or destination not in expected_repository_variables: + raise SystemExit(f"repository-variable entry {offset} has an invalid classification") + if not valid_text(item, 128) or not isinstance(field, str) or not field_pattern.fullmatch(field): + raise SystemExit(f"repository-variable entry {offset} has an invalid Proton source") + if destination in seen_repository: + raise SystemExit(f"duplicate repository variable: {destination}") + seen_repository.add(destination) + repository_lines.append("\t".join((requirement, destination, item, field))) + if not selected_environment or selected_environment == "postgres-ci-attestation": + prior = selected_source_requirements.get(item) + selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" +if seen_repository != expected_repository_variables: + raise SystemExit("repository policy variable set is incomplete") + +for offset, entry in enumerate(non_github_entries): + expected_keys = {"boundary", "requirement", "destination", "item", "field"} + if not isinstance(entry, dict) or set(entry) != expected_keys: + raise SystemExit(f"non-GitHub inventory entry {offset} has unexpected keys") + boundary = entry["boundary"] + requirement = entry["requirement"] + destination = entry["destination"] + item = entry["item"] + field = entry["field"] + if boundary not in allowed_boundaries or requirement not in allowed_requirements: + raise SystemExit(f"non-GitHub inventory entry {offset} has an invalid classification") + if not valid_text(destination) or not valid_text(item, 128) or not isinstance(field, str) or not field_pattern.fullmatch(field): + raise SystemExit(f"non-GitHub inventory entry {offset} has invalid text") + identity = (boundary, destination) + if identity in seen_non_github: + raise SystemExit(f"duplicate non-GitHub destination: {boundary}/{destination}") + seen_non_github.add(identity) + non_github_lines.append("\t".join((boundary, requirement, destination, item, field))) + if not selected_environment: + prior = selected_source_requirements.get(item) + selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" + +github_output.write_text("\n".join(github_lines) + "\n", encoding="utf-8") +repository_output.write_text("\n".join(repository_lines) + "\n", encoding="utf-8") +non_github_output.write_text("\n".join(non_github_lines) + "\n", encoding="utf-8") +sources_output.write_text( + "\n".join(f"{item}\t{requirement}" for item, requirement in sorted(selected_source_requirements.items())) + "\n", + encoding="utf-8", +) +PY + +[[ -s "${github_entries_file}" && -s "${repository_entries_file}" && -s "${selected_sources_file}" ]] || die 'the selected inventory is empty' + +while IFS=$'\t' read -r environment kind requirement destination item field; do + index=${#entry_environment[@]} + entry_environment[index]=${environment} + entry_kind[index]=${kind} + entry_requirement[index]=${requirement} + entry_destination[index]=${destination} + entry_item[index]=${item} + entry_field[index]=${field} +done <"${github_entries_file}" + +pass-cli test >/dev/null || die 'Proton Pass is not authenticated' +GH_PROMPT_DISABLED=1 gh auth status >/dev/null 2>&1 || die 'GitHub CLI is not authenticated' + +missing_required_sources=0 +missing_required_destinations=0 +missing_required_repository_variables=0 +missing_optional_sources=0 +missing_optional_destinations=0 +unexpected_destinations=0 +protection_errors=0 + +environment_selected() { + local environment=$1 + [[ -z "${selected_environment}" || "${selected_environment}" == "${environment}" ]] +} + +repository_variables_in_scope() { + [[ -z "${selected_environment}" || "${selected_environment}" == postgres-ci-attestation ]] +} + +destination_expected() { + local environment=$1 kind=$2 destination=$3 + awk -F '\t' -v environment="${environment}" -v kind="${kind}" -v destination="${destination}" \ + '$1 == environment && $2 == kind && $4 == destination { found = 1 } END { exit !found }' "${github_entries_file}" +} + +repository_variable_expected() { + local destination=$1 + awk -F '\t' -v destination="${destination}" \ + '$2 == destination { found = 1 } END { exit !found }' "${repository_entries_file}" +} + +load_names_and_policy() { + local repository_json environment environment_json branch_policy_json kind output_file + + protection_errors=0 + find "${status_root}" -maxdepth 1 -type f -name 'github-environment-*.txt' -delete + + pass-cli item list --vault-name "${vault}" --filter-state active --output json | + jq -er '.items | if type == "array" then . else error("invalid Proton item list") end | .[] | .title' | + sort >"${proton_items_file}" || die 'could not read the Proton Pass item-name inventory' + + if ! repository_json=$(GH_PROMPT_DISABLED=1 gh api "repos/${repository}" 2>/dev/null) || + ! jq -e --arg repository "${repository}" ' + .full_name == $repository and .private == true and + .default_branch == "main" and .allow_forking == false + ' >/dev/null <<<"${repository_json}"; then + printf 'REPOSITORY name=%s policy=invalid\n' "${repository}" + ((protection_errors += 1)) + else + printf 'REPOSITORY name=%s policy=private-main-nonforkable\n' "${repository}" + fi + + GH_PROMPT_DISABLED=1 gh secret list --repo "${repository}" --json name --jq '.[].name' | + sort >"${repository_secrets_file}" || die 'could not list repository-level secret names' + GH_PROMPT_DISABLED=1 gh variable list --repo "${repository}" --json name --jq '.[].name' | + sort >"${repository_variables_file}" || die 'could not list repository-level variable names' + + for environment in ${allowed_environments}; do + environment_selected "${environment}" || continue + if ! environment_json=$(GH_PROMPT_DISABLED=1 gh api "repos/${repository}/environments/${environment}" 2>/dev/null); then + printf 'ENVIRONMENT name=%s protection=missing\n' "${environment}" + ((protection_errors += 1)) + continue + fi + if ! jq -e --arg name "${environment}" ' + .name == $name and + .deployment_branch_policy.protected_branches == false and + .deployment_branch_policy.custom_branch_policies == true + ' >/dev/null <<<"${environment_json}"; then + printf 'ENVIRONMENT name=%s protection=invalid\n' "${environment}" + ((protection_errors += 1)) + continue + fi + if ! branch_policy_json=$(GH_PROMPT_DISABLED=1 gh api \ + "repos/${repository}/environments/${environment}/deployment-branch-policies?per_page=100&page=1" 2>/dev/null); then + printf 'ENVIRONMENT name=%s protection=unreadable\n' "${environment}" + ((protection_errors += 1)) + continue + fi + if ! jq -e ' + .total_count == 1 and (.branch_policies | length) == 1 and + .branch_policies[0].name == "main" and .branch_policies[0].type == "branch" + ' >/dev/null <<<"${branch_policy_json}"; then + printf 'ENVIRONMENT name=%s protection=invalid-branch-policy\n' "${environment}" + ((protection_errors += 1)) + continue + fi + printf 'ENVIRONMENT name=%s protection=main-only\n' "${environment}" + + for kind in secret variable; do + output_file=${status_root}/github-environment-${environment}-${kind}.txt + if [[ "${kind}" == secret ]]; then + GH_PROMPT_DISABLED=1 gh secret list --repo "${repository}" --env "${environment}" \ + --json name --jq '.[].name' | sort >"${output_file}" || die "could not list ${environment} secret names" + else + GH_PROMPT_DISABLED=1 gh variable list --repo "${repository}" --env "${environment}" \ + --json name --jq '.[].name' | sort >"${output_file}" || die "could not list ${environment} variable names" + fi + done + done +} + +report_status() { + local environment kind requirement destination item item_count destination_file status actual_name boundary field + missing_required_sources=0 + missing_required_destinations=0 + missing_required_repository_variables=0 + missing_optional_sources=0 + missing_optional_destinations=0 + unexpected_destinations=0 + + while IFS=$'\t' read -r item requirement; do + item_count=$(grep -Fxc -- "${item}" "${proton_items_file}" || true) + case "${item_count}" in + 1) status=present ;; + 0) status=missing ;; + *) status=ambiguous ;; + esac + printf 'SOURCE_ITEM title=%s requirement=%s status=%s\n' "${item}" "${requirement}" "${status}" + if [[ "${status}" != present ]]; then + if [[ "${requirement}" == required ]]; then + ((missing_required_sources += 1)) + else + ((missing_optional_sources += 1)) + fi + fi + done <"${selected_sources_file}" + + for index in "${!entry_environment[@]}"; do + environment=${entry_environment[index]} + kind=${entry_kind[index]} + requirement=${entry_requirement[index]} + destination=${entry_destination[index]} + destination_file=${status_root}/github-environment-${environment}-${kind}.txt + if [[ -f "${destination_file}" ]] && grep -Fqx -- "${destination}" "${destination_file}"; then + status=present + else + status=missing + if [[ "${requirement}" == required ]]; then + ((missing_required_destinations += 1)) + else + ((missing_optional_destinations += 1)) + fi + fi + printf 'DESTINATION environment=%s kind=%s name=%s requirement=%s status=%s\n' \ + "${environment}" "${kind}" "${destination}" "${requirement}" "${status}" + done + + for environment in ${allowed_environments}; do + environment_selected "${environment}" || continue + for kind in secret variable; do + destination_file=${status_root}/github-environment-${environment}-${kind}.txt + [[ -f "${destination_file}" ]] || continue + while IFS= read -r actual_name; do + [[ -n "${actual_name}" ]] || continue + if ! destination_expected "${environment}" "${kind}" "${actual_name}"; then + printf 'UNEXPECTED_DESTINATION scope=environment environment=%s kind=%s name=%s status=legacy-or-unmanaged\n' \ + "${environment}" "${kind}" "${actual_name}" + ((unexpected_destinations += 1)) + fi + done <"${destination_file}" + done + done + + if repository_variables_in_scope; then + while IFS=$'\t' read -r requirement destination item field; do + if grep -Fqx -- "${destination}" "${repository_variables_file}"; then + status=present + else + status=missing + if [[ "${requirement}" == required ]]; then + ((missing_required_destinations += 1)) + ((missing_required_repository_variables += 1)) + else + ((missing_optional_destinations += 1)) + fi + fi + printf 'REPOSITORY_DESTINATION kind=variable name=%s source=%s/%s requirement=%s status=%s\n' \ + "${destination}" "${item}" "${field}" "${requirement}" "${status}" + done <"${repository_entries_file}" + fi + + while IFS= read -r actual_name; do + [[ -n "${actual_name}" ]] || continue + printf 'UNEXPECTED_DESTINATION scope=repository kind=secret name=%s status=forbidden\n' "${actual_name}" + ((unexpected_destinations += 1)) + done <"${repository_secrets_file}" + + while IFS= read -r actual_name; do + [[ -n "${actual_name}" ]] || continue + if ! repository_variable_expected "${actual_name}"; then + printf 'UNEXPECTED_DESTINATION scope=repository kind=variable name=%s status=legacy-or-unmanaged\n' "${actual_name}" + ((unexpected_destinations += 1)) + fi + done <"${repository_variables_file}" + + if [[ -z "${selected_environment}" ]]; then + while IFS=$'\t' read -r boundary requirement destination item field; do + printf 'NON_GITHUB_DESTINATION boundary=%s name=%s source=%s/%s requirement=%s status=operator-managed\n' \ + "${boundary}" "${destination}" "${item}" "${field}" "${requirement}" + done <"${non_github_entries_file}" + fi + + printf 'SUMMARY required_source_issues=%d required_destination_missing=%d required_repository_variable_missing=%d optional_source_issues=%d optional_destination_missing=%d unexpected_destinations=%d protection_errors=%d\n' \ + "${missing_required_sources}" "${missing_required_destinations}" "${missing_required_repository_variables}" \ + "${missing_optional_sources}" "${missing_optional_destinations}" \ + "${unexpected_destinations}" "${protection_errors}" +} + +load_names_and_policy +report_status + +if [[ "${mode}" == check ]]; then + if (( protection_errors > 0 || missing_required_sources > 0 || missing_required_destinations > 0 )); then + exit 1 + fi + if (( unexpected_destinations > 0 )); then + exit 2 + fi + exit 0 +fi + +(( protection_errors == 0 )) || die 'refusing to sync into an invalid repository or environment policy' +(( unexpected_destinations == 0 )) || die 'refusing to sync while forbidden, legacy, or unmanaged GitHub names remain' +(( missing_required_sources == 0 )) || die 'required Proton Pass source items are missing or ambiguous' +(( missing_required_repository_variables == 0 )) || die 'required public repository policy variables must be reconciled separately before sync' +ulimit -c 0 || die 'could not disable process core dumps before handling credential values' + +# Complete every selected source read before the first destination write. +for index in "${!entry_environment[@]}"; do + item=${entry_item[index]} + field=${entry_field[index]} + requirement=${entry_requirement[index]} + value= + if ! value=$(pass-cli item view --vault-name "${vault}" --item-title "${item}" --field "${field}" 2>/dev/null); then + if [[ "${requirement}" == optional ]]; then + destination_file=${status_root}/github-environment-${entry_environment[index]}-${entry_kind[index]}.txt + if [[ -f "${destination_file}" ]] && grep -Fqx -- "${entry_destination[index]}" "${destination_file}"; then + die "optional Proton field is absent while its GitHub destination remains: ${item}/${field}" + fi + source_available[index]=0 + printf 'SOURCE_FIELD item=%s field=%s requirement=optional status=missing\n' "${item}" "${field}" + continue + fi + die "required Proton field is missing or unreadable: ${item}/${field}" + fi + if [[ -z "${value}" ]]; then + if [[ "${requirement}" == optional ]]; then + destination_file=${status_root}/github-environment-${entry_environment[index]}-${entry_kind[index]}.txt + if [[ -f "${destination_file}" ]] && grep -Fqx -- "${entry_destination[index]}" "${destination_file}"; then + die "optional Proton field is empty while its GitHub destination remains: ${item}/${field}" + fi + source_available[index]=0 + printf 'SOURCE_FIELD item=%s field=%s requirement=optional status=empty\n' "${item}" "${field}" + continue + fi + die "required Proton field is empty: ${item}/${field}" + fi + (( ${#value} <= max_value_bytes )) || die "Proton field exceeds GitHub's bounded value size: ${item}/${field}" + source_values[index]=${value} + source_available[index]=1 + unset value +done + +# Close the useful provider race after all field reads and before any write. +load_names_and_policy +report_status +(( protection_errors == 0 )) || die 'repository or environment protection changed during source preflight' +(( unexpected_destinations == 0 )) || die 'a forbidden or unmanaged GitHub name appeared during source preflight' +(( missing_required_repository_variables == 0 )) || die 'a required repository policy variable disappeared during source preflight' + +for index in "${!entry_environment[@]}"; do + [[ "${source_available[index]:-0}" == 1 ]] || continue + environment=${entry_environment[index]} + kind=${entry_kind[index]} + destination=${entry_destination[index]} + if [[ "${kind}" == secret ]]; then + if ! printf '%s' "${source_values[index]}" | + GH_PROMPT_DISABLED=1 gh secret set "${destination}" --repo "${repository}" --env "${environment}" >/dev/null 2>&1; then + die "GitHub rejected ${environment}/${kind}/${destination}" + fi + else + if ! printf '%s' "${source_values[index]}" | + GH_PROMPT_DISABLED=1 gh variable set "${destination}" --repo "${repository}" --env "${environment}" >/dev/null 2>&1; then + die "GitHub rejected ${environment}/${kind}/${destination}" + fi + fi + unset 'source_values[index]' + printf 'SYNCED environment=%s kind=%s name=%s\n' "${environment}" "${kind}" "${destination}" +done + +load_names_and_policy +report_status +(( protection_errors == 0 && missing_required_destinations == 0 && unexpected_destinations == 0 )) || \ + die 'GitHub destination read-back did not match the reviewed inventory' +printf 'SYNC_COMPLETE repository=%s vault=%s environment=%s\n' "${repository}" "${vault}" "${selected_environment}" From 20a85a604e653f7e6772b1d481ab3b174ba4057f Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 06:43:27 +0200 Subject: [PATCH 03/16] test(credentials): cover adversarial sync boundaries --- scripts/run-ci.sh | 3 + scripts/test-sync-github-environments.sh | 344 +++++++++++++++++++++++ scripts/validate-postgres-config.sh | 181 +++++++----- 3 files changed, 460 insertions(+), 68 deletions(-) create mode 100755 scripts/test-sync-github-environments.sh diff --git a/scripts/run-ci.sh b/scripts/run-ci.sh index 6cdab10..dd34d1e 100755 --- a/scripts/run-ci.sh +++ b/scripts/run-ci.sh @@ -34,6 +34,8 @@ shellcheck \ scripts/run-postgres-ci-jit-vm.sh \ scripts/run-postgres-ci-queue-controller.sh \ scripts/configure-postgres-ci-runner-group.sh \ + scripts/sync-github-environments.sh \ + scripts/test-sync-github-environments.sh \ scripts/fixtures/brio-deployment-failure-fixture.sh \ scripts/fixtures/keycloak-cohort-cleaner-fixture.sh \ scripts/fixtures/keycloak-cohort-dispatch-fixture.sh @@ -52,6 +54,7 @@ for source in ( ast.parse(Path(source).read_text(), filename=source) PY PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-github-environment-main-policy.py +./scripts/test-sync-github-environments.sh node --check scripts/publish-pr-ci-check.mjs node --check scripts/postgres-ci-queue-controller.mjs node --check scripts/dispatch-ci-attestation.mjs diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh new file mode 100755 index 0000000..0743da6 --- /dev/null +++ b/scripts/test-sync-github-environments.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +readonly repo_root +readonly helper=${repo_root}/scripts/sync-github-environments.sh +readonly inventory=${repo_root}/deploy/credential-inventory.json + +test_root=$(mktemp -d "${TMPDIR:-/tmp}/postgres-credential-sync-test.XXXXXXXX") +[[ -d "${test_root}" && ! -L "${test_root}" ]] +readonly test_root +readonly fake_bin=${test_root}/bin +readonly audit_log=${test_root}/audit.log +install -d -m 0700 "${fake_bin}" + +cleanup() { + if [[ "${test_root}" == "${TMPDIR:-/tmp}/postgres-credential-sync-test."* && -d "${test_root}" && ! -L "${test_root}" ]]; then + find "${test_root}" -depth -mindepth 1 -delete + rmdir -- "${test_root}" + fi +} +trap cleanup EXIT + +cat >"${fake_bin}/pass-cli" <<'FAKE_PASS' +#!/usr/bin/env bash +set -euo pipefail +printf 'pass-cli' >>"${FAKE_AUDIT_LOG}" +printf ' %q' "$@" >>"${FAKE_AUDIT_LOG}" +printf '\n' >>"${FAKE_AUDIT_LOG}" + +if [[ "${1:-} ${2:-}" == 'item list' ]]; then + jq --arg missing "${FAKE_MISSING_ITEM:-}" ' + {items: ([.githubEntries[].item, .repositoryVariables[].item, .nonGitHubEntries[].item] + | unique | map(select(. != $missing) | {title: .}))} + ' "${FAKE_INVENTORY}" + exit 0 +fi +if [[ "${1:-} ${2:-}" == 'item view' ]]; then + field= + while (( $# > 0 )); do + if [[ "$1" == --field ]]; then + field=$2 + break + fi + shift + done + [[ -n "${field}" ]] + [[ "${field}" != "${FAKE_MISSING_FIELD:-}" ]] || exit 1 + if [[ "${field}" == "${FAKE_EMPTY_FIELD:-}" ]]; then + exit 0 + fi + if [[ "${field}" == "${FAKE_OVERSIZED_FIELD:-}" ]]; then + printf '%050000d' 0 + exit 0 + fi + printf 'HIGHLY_SECRET_%s' "${field}" + exit 0 +fi +if [[ "${1:-}" == test ]]; then + exit 0 +fi +exit 97 +FAKE_PASS + +cat >"${fake_bin}/gh" <<'FAKE_GH' +#!/usr/bin/env bash +set -euo pipefail + +printf 'gh' >>"${FAKE_AUDIT_LOG}" +printf ' %q' "$@" >>"${FAKE_AUDIT_LOG}" +printf '\n' >>"${FAKE_AUDIT_LOG}" +if printf '%s\n' "$*" | grep -Fq 'HIGHLY_SECRET_'; then + echo 'a synthetic credential reached gh argv' >&2 + exit 95 +fi +if env | grep -Fq 'HIGHLY_SECRET_'; then + echo 'a synthetic credential reached the exported environment' >&2 + exit 94 +fi + +if [[ "${1:-} ${2:-}" == 'auth status' ]]; then + exit 0 +fi +if [[ "${1:-}" == api ]]; then + path=${*: -1} + if [[ "${path}" == repos/Makepad-fr/postgres ]]; then + if [[ "${FAKE_INVALID_REPOSITORY:-0}" == 1 ]]; then + printf '%s\n' '{"full_name":"Makepad-fr/postgres","private":false,"default_branch":"main","allow_forking":true}' + else + printf '%s\n' '{"full_name":"Makepad-fr/postgres","private":true,"default_branch":"main","allow_forking":false}' + fi + exit 0 + fi + if [[ "${path}" == */deployment-branch-policies\?per_page=100\&page=1 ]]; then + case "${FAKE_INVALID_PROTECTION:-0}" in + 1) printf '%s\n' '{"total_count":2,"branch_policies":[{"name":"main","type":"branch"},{"name":"release/*","type":"branch"}]}' ;; + tag) printf '%s\n' '{"total_count":1,"branch_policies":[{"name":"main","type":"tag"}]}' ;; + *) printf '%s\n' '{"total_count":1,"branch_policies":[{"name":"main","type":"branch"}]}' ;; + esac + exit 0 + fi + environment=${path##*/} + if [[ "${FAKE_INVALID_PROTECTION:-0}" == mode ]]; then + printf '{"name":"%s","deployment_branch_policy":{"protected_branches":true,"custom_branch_policies":false}}\n' "${environment}" + else + printf '{"name":"%s","deployment_branch_policy":{"protected_branches":false,"custom_branch_policies":true}}\n' "${environment}" + fi + exit 0 +fi + +kind=${1:-} +operation=${2:-} +if [[ "${kind}" != secret && "${kind}" != variable ]]; then + exit 96 +fi +shift 2 +environment= +destination= +if [[ "${operation}" == set ]]; then + destination=${1:-} + shift +fi +while (( $# > 0 )); do + case "$1" in + --env) + environment=$2 + shift 2 + ;; + *) shift ;; + esac +done + +if [[ "${operation}" == list ]]; then + if [[ -z "${environment}" ]]; then + if [[ "${kind}" == variable ]]; then + jq -r --arg missing "${FAKE_MISSING_REPOSITORY_VARIABLE:-}" ' + .repositoryVariables[] | select(.destination != $missing) | .destination + ' "${FAKE_INVENTORY}" + fi + if [[ "${kind}" == "${FAKE_REPOSITORY_LEGACY_KIND:-}" && -n "${FAKE_REPOSITORY_LEGACY:-}" ]]; then + printf '%s\n' "${FAKE_REPOSITORY_LEGACY}" + fi + exit 0 + fi + jq -r --arg environment "${environment}" --arg kind "${kind}" --arg missing "${FAKE_MISSING_DESTINATION:-}" ' + .githubEntries[] | + select(.environment == $environment and .kind == $kind and .destination != $missing) | + .destination + ' "${FAKE_INVENTORY}" + if [[ -n "${FAKE_UNEXPECTED_DESTINATION:-}" && "${FAKE_UNEXPECTED_ENVIRONMENT:-canary}" == "${environment}" && "${FAKE_UNEXPECTED_KIND:-secret}" == "${kind}" ]]; then + printf '%s\n' "${FAKE_UNEXPECTED_DESTINATION}" + fi + exit 0 +fi + +[[ "${operation}" == set && -n "${environment}" && -n "${destination}" ]] +bytes=$(wc -c | tr -d '[:space:]') +[[ "${bytes}" =~ ^[1-9][0-9]*$ ]] +printf 'gh-set environment=%s kind=%s name=%s bytes=%s\n' \ + "${environment}" "${kind}" "${destination}" "${bytes}" >>"${FAKE_AUDIT_LOG}" +FAKE_GH + +chmod 0755 "${fake_bin}/pass-cli" "${fake_bin}/gh" + +run_helper() { + local expected_status=$1 + shift + set +e + output=$(PATH="${fake_bin}:${PATH}" FAKE_INVENTORY="${inventory}" FAKE_AUDIT_LOG="${audit_log}" "$@" 2>&1) + status=$? + set -e + if (( status != expected_status )); then + printf 'unexpected status %d (wanted %d):\n%s\n' "${status}" "${expected_status}" "${output}" >&2 + exit 1 + fi +} + +assert_no_value_read_or_write() { + if grep -Fq 'pass-cli item view' "${audit_log}" || grep -Fq 'gh-set ' "${audit_log}"; then + echo 'provider field read or destination write occurred before a failing preflight' >&2 + exit 1 + fi +} + +# The machine-readable inventory encodes the exact workflow-backed PKI split. +jq -e ' + ([.githubEntries[] | select(.item == "Brio Staging - PKI and Backup Keys" and (.destination | endswith("_PEM"))) | [.environment, .destination]] | sort) == + ([ + ["canary", "BRIO_BACKUP_RECIPIENT_CERT_PEM"], + ["canary", "POSTGRES_CA_PEM"], + ["canary", "POSTGRES_SERVER_CERT_PEM"], + ["canary", "POSTGRES_SERVER_KEY_PEM"], + ["staging-brio-identity-db", "BRIO_BACKUP_RECIPIENT_CERT_PEM"] + ] | sort) and + ([.repositoryVariables[].destination] | sort) == ([ + "POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256", + "POSTGRES_CI_ATTESTATION_PUBLIC_KEY", + "POSTGRES_CI_LAUNCHER_APP_SENDER_ID", + "POSTGRES_PR_CHECK_APP_ID" + ] | sort) and + ([.githubEntries[] | select(.destination | test("PASSWORD|TOKEN|PRIVATE_KEY|SERVER_KEY")) | .kind] | all(. == "secret")) and + ([.nonGitHubEntries[] | select(.destination | test("controller.env|private-key|HOST_ALERT"))] | length >= 5) +' "${inventory}" >/dev/null + +: >"${audit_log}" +run_helper 0 "${helper}" --check +grep -Fq 'NON_GITHUB_DESTINATION boundary=host-root-file' <<<"${output}" +grep -Fq 'SUMMARY required_source_issues=0 required_destination_missing=0' <<<"${output}" +if grep -Fq 'item view' "${audit_log}" || grep -Fq 'HIGHLY_SECRET_' <<<"${output}"; then + echo 'check mode read or printed a Proton field value' >&2 + exit 1 +fi + +run_helper 1 "${helper}" --check --sync +grep -Fq 'select exactly one mode' <<<"${output}" +run_helper 1 "${helper}" --sync +grep -Fq -- '--sync requires one explicit --environment' <<<"${output}" +run_helper 1 "${helper}" --check --environment arbitrary-environment +grep -Fq 'environment is not in the immutable PostgreSQL inventory' <<<"${output}" + +: >"${audit_log}" +run_helper 1 env FAKE_MISSING_DESTINATION=KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN \ + "${helper}" --check --environment release-brio-identity-db +grep -Fq 'name=KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN requirement=required status=missing' <<<"${output}" +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 1 env FAKE_MISSING_ITEM='PostgreSQL · shared Swarm deployment' \ + "${helper}" --check --environment production +grep -Fq 'title=PostgreSQL · shared Swarm deployment requirement=required status=missing' <<<"${output}" +assert_no_value_read_or_write + +# A selected environment does not inspect unrelated root-only item fields. +: >"${audit_log}" +run_helper 0 env FAKE_MISSING_ITEM='PostgreSQL · CI hypervisor alert' \ + "${helper}" --check --environment staging-brio-identity-db +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 2 env FAKE_UNEXPECTED_DESTINATION=LEGACY_KEYCLOAK_PASSWORD \ + "${helper}" --check --environment canary +grep -Fq 'scope=environment environment=canary kind=secret name=LEGACY_KEYCLOAK_PASSWORD status=legacy-or-unmanaged' <<<"${output}" +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 2 env FAKE_REPOSITORY_LEGACY=LEGACY_REPOSITORY_TOKEN FAKE_REPOSITORY_LEGACY_KIND=secret \ + "${helper}" --check --environment canary +grep -Fq 'scope=repository kind=secret name=LEGACY_REPOSITORY_TOKEN status=forbidden' <<<"${output}" +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 2 env FAKE_REPOSITORY_LEGACY=LEGACY_POLICY_ID FAKE_REPOSITORY_LEGACY_KIND=variable \ + "${helper}" --check --environment canary +grep -Fq 'scope=repository kind=variable name=LEGACY_POLICY_ID status=legacy-or-unmanaged' <<<"${output}" +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 1 env FAKE_INVALID_REPOSITORY=1 "${helper}" --sync --environment canary +grep -Fq 'REPOSITORY name=Makepad-fr/postgres policy=invalid' <<<"${output}" +assert_no_value_read_or_write + +for invalid_policy in 1 tag mode; do + : >"${audit_log}" + run_helper 1 env FAKE_INVALID_PROTECTION="${invalid_policy}" \ + "${helper}" --sync --environment release-brio-identity-db + grep -Eq 'protection=(invalid|invalid-branch-policy)' <<<"${output}" + assert_no_value_read_or_write +done + +: >"${audit_log}" +run_helper 1 env FAKE_MISSING_REPOSITORY_VARIABLE=POSTGRES_CI_ATTESTATION_PUBLIC_KEY \ + "${helper}" --sync --environment postgres-ci-attestation +grep -Fq 'required_repository_variable_missing=1' <<<"${output}" +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 1 env FAKE_MISSING_FIELD=KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN \ + "${helper}" --sync --environment release-brio-identity-db +grep -Fq 'required Proton field is missing or unreadable' <<<"${output}" +if grep -Fq 'gh-set ' "${audit_log}" || grep -Fq 'HIGHLY_SECRET_' <<<"${output}"; then + echo 'failed source preflight wrote a destination or printed a value' >&2 + exit 1 +fi + +: >"${audit_log}" +run_helper 1 env FAKE_EMPTY_FIELD=KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN \ + "${helper}" --sync --environment release-brio-identity-db +grep -Fq 'required Proton field is empty' <<<"${output}" +if grep -Fq 'gh-set ' "${audit_log}"; then + echo 'empty source field reached GitHub' >&2 + exit 1 +fi + +: >"${audit_log}" +run_helper 1 env FAKE_OVERSIZED_FIELD=KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN \ + "${helper}" --sync --environment release-brio-identity-db +grep -Fq "Proton field exceeds GitHub's bounded value size" <<<"${output}" +if grep -Fq 'gh-set ' "${audit_log}"; then + echo 'oversized source field reached GitHub' >&2 + exit 1 +fi + +: >"${audit_log}" +run_helper 1 env FAKE_UNEXPECTED_DESTINATION=LEGACY_KEYCLOAK_PASSWORD \ + "${helper}" --sync --environment canary +grep -Fq 'refusing to sync while forbidden, legacy, or unmanaged GitHub names remain' <<<"${output}" +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 0 "${helper}" --sync --environment staging-brio-identity-db +grep -Fq 'SYNC_COMPLETE repository=Makepad-fr/postgres vault=Makepad environment=staging-brio-identity-db' <<<"${output}" +if grep -Fq 'HIGHLY_SECRET_' <<<"${output}"; then + echo 'successful sync printed a field value' >&2 + exit 1 +fi +expected_syncs=$(jq '[.githubEntries[] | select(.environment == "staging-brio-identity-db")] | length' "${inventory}") +actual_syncs=$(grep -Fc 'gh-set ' "${audit_log}") +[[ "${actual_syncs}" == "${expected_syncs}" ]] +last_source_read=$(grep -n 'pass-cli item view' "${audit_log}" | tail -n 1 | cut -d: -f1) +first_destination_write=$(grep -n 'gh-set ' "${audit_log}" | head -n 1 | cut -d: -f1) +[[ "${last_source_read}" =~ ^[1-9][0-9]*$ && "${first_destination_write}" =~ ^[1-9][0-9]*$ ]] +(( last_source_read < first_destination_write )) + +# Malformed or reclassified inventories fail before any provider call. +candidate_root=${test_root}/candidate +install -d -m 0700 "${candidate_root}/scripts" "${candidate_root}/deploy" +cp "${helper}" "${candidate_root}/scripts/sync-github-environments.sh" +chmod 0755 "${candidate_root}/scripts/sync-github-environments.sh" + +jq '(.githubEntries[] | select(.destination == "BRIO_IDENTITY_DB_HOSTNAME")).kind = "secret"' \ + "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" +: >"${audit_log}" +run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check --environment staging-brio-identity-db +grep -Fq 'wrong public/secret classification' <<<"${output}" +[[ ! -s "${audit_log}" ]] + +jq '.githubEntries += [{"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"POSTGRES_SERVER_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"POSTGRES_SERVER_CERT_PEM"}]' \ + "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" +: >"${audit_log}" +run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check --environment staging-brio-identity-db +grep -Fq 'PKI destinations do not match' <<<"${output}" +[[ ! -s "${audit_log}" ]] + +printf '%s\n' 'PostgreSQL Proton-to-GitHub credential sync tests passed.' diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index bebc427..8ff4cad 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -10,6 +10,7 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) repo_root=$(cd "${script_dir}/.." && pwd) REPO_ROOT="${repo_root}" python3 - <<'PY' +import json import os import re from pathlib import Path @@ -104,6 +105,8 @@ pr_jit_result_validator = read_required_text(pr_jit_result_validator_path, "PR J pr_runner_policy = read_required_text(repo_root / "scripts/configure-postgres-ci-runner-group.sh", "runner-group policy reconciler") environment_policy_reconciler = read_required_text(repo_root / "scripts/reconcile-github-environment-main-policy.py", "GitHub environment policy reconciler") environment_policy_test = read_required_text(repo_root / "scripts/test-github-environment-main-policy.py", "GitHub environment policy test") +credential_sync = read_required_text(repo_root / "scripts/sync-github-environments.sh", "credential sync helper") +credential_sync_test = read_required_text(repo_root / "scripts/test-sync-github-environments.sh", "credential sync behavioral test") release_evidence_validator = read_required_text(repo_root / "scripts/verify-brio-release-evidence.py", "Brio release evidence validator") cohort_evidence_validator = read_required_text(repo_root / "scripts/verify-keycloak-cohort-evidence.py", "Keycloak cohort evidence validator") cohort_capture = read_required_text(repo_root / "scripts/capture-keycloak-cohort-backups.sh", "Keycloak cohort backup capture") @@ -686,77 +689,119 @@ for workflow_path in workflow_paths: ) # Credential material is canonical in Proton Pass and may be mirrored only to -# the protected environment that consumes it. Validate each complete table row -# so a field cannot silently drift into a different environment or item. -credential_inventory = { - "Hetzner Database Server makepad": ( - ("canary", "production", "staging-brio-identity-db", "keycloak-cohort-restore"), - ( - "DEPLOY_SSH_HOST", "DEPLOY_SSH_PORT", "DEPLOY_SSH_USER", - "DEPLOY_SSH_PRIVATE_KEY", "DEPLOY_SSH_KNOWN_HOSTS", - "BRIO_IDENTITY_DB_DEPLOY_SSH_HOST", "BRIO_IDENTITY_DB_DEPLOY_SSH_PORT", - "BRIO_IDENTITY_DB_DEPLOY_SSH_USER", "BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY", - "BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS", "KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY", - "KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS", "KEYCLOAK_COHORT_DB_SSH_HOST", - "KEYCLOAK_COHORT_DB_SSH_PORT", "KEYCLOAK_COHORT_DB_SSH_USER", - ), - ), - "Brio Staging - PostgreSQL": ( - ("canary", "staging-brio-identity-db"), - ( - "POSTGRES_CANARY_SUPERUSER_PASSWORD", "BRIO_STAGING_DB_PASSWORD", - "BRIO_STAGING_BACKUP_DB_PASSWORD", "KEYCLOAK_BRIO_STAGING_DB_PASSWORD", - "KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD", - ), - ), - "Brio Staging - PKI and Backup Keys": ( - ("canary", "staging-brio-identity-db"), - ( - "POSTGRES_CA_PEM", "POSTGRES_SERVER_CERT_PEM", "POSTGRES_SERVER_KEY_PEM", - "BRIO_BACKUP_RECIPIENT_CERT_PEM", - ), - ), - "PostgreSQL · Brio identity release orchestrator": ( - ("release-brio-identity-db",), - ("KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN",), - ), - "PostgreSQL · Keycloak cohort source reader": ( - ("keycloak-cohort-restore",), - ("KEYCLOAK_COHORT_SOURCE_TOKEN",), - ), - "Makepad Docker Hardened Images": ( - ("keycloak-cohort-restore",), - ("DOCKERHUB_USERNAME", "DOCKERHUB_PRO_PAT", "DHI_REGISTRY_USERNAME", "DHI_REGISTRY_PASSWORD"), - ), - "PostgreSQL · PR Checks App": ( - ("postgres-ci-attestation",), - ( - "POSTGRES_PR_CHECK_APP_ID", "POSTGRES_PR_CHECK_APP_PRIVATE_KEY", - ), - ), - "PostgreSQL · JIT Launcher App": ( - ("postgres-ci-attestation",), - ("POSTGRES_CI_LAUNCHER_APP_SENDER_ID",), - ), - "PostgreSQL · JIT hypervisor attestation": ( - ("postgres-ci-attestation",), - ("POSTGRES_CI_ATTESTATION_PUBLIC_KEY", "POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256"), - ), +# its reviewed consumer. The machine-readable inventory, rather than a prose +# table substring, is the exact contract checked against every workflow. +credential_inventory_path = repo_root / "deploy/credential-inventory.json" +credential_inventory = json.loads(read_required_text(credential_inventory_path, "credential inventory")) +require( + set(credential_inventory) == { + "schemaVersion", "repository", "vault", "githubEntries", + "repositoryVariables", "nonGitHubEntries", + }, + "Credential inventory has unexpected top-level keys.", +) +require(credential_inventory["schemaVersion"] == 1, "Credential inventory schema must be version 1.") +require(credential_inventory["repository"] == "Makepad-fr/postgres", "Credential inventory targets the wrong repository.") +require(credential_inventory["vault"] == "Makepad", "Credential inventory targets the wrong Proton vault.") + +github_entries = credential_inventory["githubEntries"] +repository_variables = credential_inventory["repositoryVariables"] +non_github_entries = credential_inventory["nonGitHubEntries"] +require(all(isinstance(entries, list) and entries for entries in (github_entries, repository_variables, non_github_entries)), "Every credential inventory section must be non-empty.") +required_environments = { + "canary", "production", "staging-brio-identity-db", + "release-brio-identity-db", "keycloak-cohort-restore", + "postgres-ci-attestation", } -readme_lines = readme.splitlines() -for item, (environments, fields) in credential_inventory.items(): - candidate_rows = [line for line in readme_lines if line.startswith("|") and f"`{item}`" in line] - require(candidate_rows, f"README credential inventory is missing canonical Proton item {item}.") - require( - any(all(value in row for value in (*environments, *fields)) for row in candidate_rows), - f"README must map every field for {item} to its exact protected GitHub environment.", - ) -require("pass-cli item view --item-title '' --field ''" in readme, "README must document stdin-only pass-cli credential synchronization.") -require("| gh secret set '' --env '' --repo 'Makepad-fr/postgres'" in normalized_readme, "README must mirror workflow secrets only into protected GitHub environments.") +require({entry.get("environment") for entry in github_entries} == required_environments, "Credential inventory environment set drifted.") +github_destinations = { + (entry.get("environment"), entry.get("kind"), entry.get("destination")) + for entry in github_entries +} +require(len(github_destinations) == len(github_entries), "Credential inventory has duplicate environment destinations.") +repository_destinations = {entry.get("destination") for entry in repository_variables} +require(len(repository_destinations) == len(repository_variables), "Credential inventory has duplicate repository variables.") +require( + repository_destinations == { + "POSTGRES_CI_LAUNCHER_APP_SENDER_ID", + "POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256", + "POSTGRES_CI_ATTESTATION_PUBLIC_KEY", + "POSTGRES_PR_CHECK_APP_ID", + }, + "Public repository policy variable inventory drifted.", +) + +secret_references = set() +variable_references = set() for workflow_path in workflow_paths: workflow_text = read_required_text(workflow_path, f"workflow {workflow_path.name}") - for field in set(re.findall(r"(?:secrets|vars)\.([A-Z][A-Z0-9_]*)", workflow_text)): - require(field in readme, f"Workflow field {field} in {workflow_path.name} is absent from the credential inventory.") + secret_references.update(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", workflow_text)) + variable_references.update(re.findall(r"vars\.([A-Z][A-Z0-9_]*)", workflow_text)) +for entry in github_entries: + destination = entry.get("destination") + kind = entry.get("kind") + require(kind in {"secret", "variable"}, f"Credential destination {destination} has an invalid kind.") + expected_references = secret_references if kind == "secret" else variable_references + require(destination in expected_references, f"Credential destination {destination} has the wrong kind or no workflow consumer.") +inventory_secret_names = {entry["destination"] for entry in github_entries if entry["kind"] == "secret"} +inventory_variable_names = {entry["destination"] for entry in github_entries if entry["kind"] == "variable"} | repository_destinations +require(secret_references <= inventory_secret_names, "A workflow secret is absent from the protected-environment inventory.") +require(variable_references <= inventory_variable_names, "A workflow variable is absent from the reviewed variable inventory.") + +pki_item = "Brio Staging - PKI and Backup Keys" +pki_destinations = { + (entry["environment"], entry["destination"]) + for entry in github_entries if entry["item"] == pki_item and entry["destination"].endswith("_PEM") +} +require( + pki_destinations == { + ("canary", "POSTGRES_CA_PEM"), + ("canary", "POSTGRES_SERVER_CERT_PEM"), + ("canary", "POSTGRES_SERVER_KEY_PEM"), + ("canary", "BRIO_BACKUP_RECIPIENT_CERT_PEM"), + ("staging-brio-identity-db", "BRIO_BACKUP_RECIPIENT_CERT_PEM"), + }, + "Brio PKI fields must match their exact workflow destinations.", +) +require(all(entry.get("boundary") in {"host-root-file", "host-root-setting", "operator-stdin", "operator-verification"} for entry in non_github_entries), "Non-GitHub credential boundary is invalid.") +for canonical_item in { + "Hetzner Database Server makepad", "Brio Staging - PostgreSQL", + pki_item, "PostgreSQL · Brio identity release orchestrator", + "PostgreSQL · Keycloak cohort source reader", "Makepad Docker Hardened Images", + "PostgreSQL · PR Checks App", "PostgreSQL · JIT Launcher App", + "PostgreSQL · JIT hypervisor attestation", +}: + require(canonical_item in readme, f"README credential inventory is missing canonical Proton item {canonical_item}.") +for required in ( + "--sync requires one explicit --environment", + "pass-cli item list", + "pass-cli item view", + 'gh secret set "${destination}" --repo "${repository}" --env "${environment}"', + 'gh variable set "${destination}" --repo "${repository}" --env "${environment}"', + "REPOSITORY name=%s policy=private-main-nonforkable", + 'status=forbidden', + 'branch_policies[0].name == "main"', + 'branch_policies[0].type == "branch"', +): + require(required in credential_sync, f"Credential sync helper is missing fail-closed control: {required}") +for forbidden in ("gh secret delete", "gh variable delete", "pass-cli item delete"): + require(forbidden not in credential_sync, f"Credential sync helper must never delete provider state: {forbidden}") +require("if [[ \"${mode}\" == check ]]" in credential_sync, "Credential sync helper must branch before Proton field reads.") +require(credential_sync.find('if [[ "${mode}" == check ]]') < credential_sync.find("pass-cli item view"), "Check mode must exit before any Proton field-value read.") +for required in ( + "assert_no_value_read_or_write", + "FAKE_REPOSITORY_LEGACY_KIND=secret", + "FAKE_INVALID_REPOSITORY=1", + "FAKE_INVALID_PROTECTION", + "FAKE_MISSING_FIELD", + "FAKE_OVERSIZED_FIELD", + "wrong public/secret classification", + "PKI destinations do not match", +): + require(required in credential_sync_test, f"Credential sync behavioral test is missing adversarial case: {required}") +require("./scripts/test-sync-github-environments.sh" in ci_runner, "CI must run the credential sync behavioral test.") +require("pass-cli item view --item-title '' --field ''" in readme, "README must document stdin-only pass-cli credential synchronization.") +require("| gh secret set '' --env '' --repo 'Makepad-fr/postgres'" in normalized_readme, "README must mirror workflow secrets only into protected GitHub environments.") for policy in ( "hostnossl brio_staging", "hostnossl keycloak_brio_staging", From e425d80a32187163362c69e6a293a1437e89f8a5 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 06:44:54 +0200 Subject: [PATCH 04/16] fix(credentials): honor public Postgres boundary --- docs/credential-sync.md | 20 +++++++++++--------- scripts/sync-github-environments.sh | 7 ++++--- scripts/test-sync-github-environments.sh | 4 ++-- scripts/validate-postgres-config.sh | 2 +- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/credential-sync.md b/docs/credential-sync.md index 863c3a2..04d0cc1 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -32,15 +32,17 @@ environment: ``` Sync mode rejects an omitted or arbitrary environment. Before its first field -read it proves the repository is private, non-forkable, and uses `main` as its -default branch; proves the selected environment has exactly one custom branch -policy named and typed `main`; rejects every repository-level secret; and -rejects unlisted environment or repository names. It then reads every selected -Proton field before the first GitHub write, rechecks provider names and policy, -and streams each value to `gh secret set` or `gh variable set` over standard -input. Values exist briefly only in process memory: tracing/debug output and -core dumps are disabled, and values never enter arguments, exported child -environments, logs, or files. +read it proves this intentionally public repository is active and uses `main` +as its default branch; proves the selected environment has exactly one custom +branch policy named and typed `main`; rejects every repository-level secret; +and rejects unlisted environment or repository names. Public forks are treated +as untrusted: protected workflow and disposable-runner controls remain the +execution boundary. The helper then reads every selected Proton field before +the first GitHub write, rechecks provider names and policy, and streams each +value to `gh secret set` or `gh variable set` over standard input. Values exist +briefly only in process memory: tracing/debug output and core dumps are +disabled, and values never enter arguments, exported child environments, logs, +or files. The helper never creates an environment, changes a branch policy, modifies Proton Pass, sets repository-level values, or deletes a GitHub name. A legacy diff --git a/scripts/sync-github-environments.sh b/scripts/sync-github-environments.sh index 92e78a8..9368011 100755 --- a/scripts/sync-github-environments.sh +++ b/scripts/sync-github-environments.sh @@ -334,13 +334,14 @@ load_names_and_policy() { if ! repository_json=$(GH_PROMPT_DISABLED=1 gh api "repos/${repository}" 2>/dev/null) || ! jq -e --arg repository "${repository}" ' - .full_name == $repository and .private == true and - .default_branch == "main" and .allow_forking == false + .full_name == $repository and .private == false and + .visibility == "public" and .default_branch == "main" and + .allow_forking == true and .archived == false and .disabled == false ' >/dev/null <<<"${repository_json}"; then printf 'REPOSITORY name=%s policy=invalid\n' "${repository}" ((protection_errors += 1)) else - printf 'REPOSITORY name=%s policy=private-main-nonforkable\n' "${repository}" + printf 'REPOSITORY name=%s policy=public-active-main\n' "${repository}" fi GH_PROMPT_DISABLED=1 gh secret list --repo "${repository}" --json name --jq '.[].name' | diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index 0743da6..f4a5ada 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -85,9 +85,9 @@ if [[ "${1:-}" == api ]]; then path=${*: -1} if [[ "${path}" == repos/Makepad-fr/postgres ]]; then if [[ "${FAKE_INVALID_REPOSITORY:-0}" == 1 ]]; then - printf '%s\n' '{"full_name":"Makepad-fr/postgres","private":false,"default_branch":"main","allow_forking":true}' + printf '%s\n' '{"full_name":"Makepad-fr/postgres","private":true,"visibility":"private","default_branch":"main","allow_forking":false,"archived":false,"disabled":false}' else - printf '%s\n' '{"full_name":"Makepad-fr/postgres","private":true,"default_branch":"main","allow_forking":false}' + printf '%s\n' '{"full_name":"Makepad-fr/postgres","private":false,"visibility":"public","default_branch":"main","allow_forking":true,"archived":false,"disabled":false}' fi exit 0 fi diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 8ff4cad..4564ab2 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -778,7 +778,7 @@ for required in ( "pass-cli item view", 'gh secret set "${destination}" --repo "${repository}" --env "${environment}"', 'gh variable set "${destination}" --repo "${repository}" --env "${environment}"', - "REPOSITORY name=%s policy=private-main-nonforkable", + "REPOSITORY name=%s policy=public-active-main", 'status=forbidden', 'branch_policies[0].name == "main"', 'branch_policies[0].type == "branch"', From 5a239a1b3d4ed7a1858d285726df289f8e16a7f2 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 06:45:32 +0200 Subject: [PATCH 05/16] docs(credentials): clarify workflow-backed mappings --- README.md | 14 ++++++++------ scripts/validate-postgres-config.sh | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 08bd4b6..97bc74d 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,8 @@ GitHub environment variables. The exact Brio inventory is: | Canonical Proton Pass item | Protected GitHub environment | Exact mirrored fields | | --- | --- | --- | | `Hetzner Database Server makepad` | `canary`, `production`, `staging-brio-identity-db`, and `keycloak-cohort-restore` | canonical SSH fields `DEPLOY_SSH_HOST`, `DEPLOY_SSH_PORT`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_PRIVATE_KEY`, `DEPLOY_SSH_KNOWN_HOSTS`; mirror the same reviewed values under the workflow aliases `BRIO_IDENTITY_DB_DEPLOY_SSH_HOST`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PORT`, `BRIO_IDENTITY_DB_DEPLOY_SSH_USER`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY`, `BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS`, `KEYCLOAK_COHORT_DB_SSH_HOST`, `KEYCLOAK_COHORT_DB_SSH_PORT`, `KEYCLOAK_COHORT_DB_SSH_USER`, `KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY`, and `KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS` only in their named environments | -| `Brio Staging - PostgreSQL` | `canary` and `staging-brio-identity-db` | secrets `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, `BRIO_STAGING_BACKUP_DB_PASSWORD`, `KEYCLOAK_BRIO_STAGING_DB_PASSWORD`, and `KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD` | +| `PostgreSQL · shared Swarm deployment` | `canary` and `production` | current workflow-compatible protected fields for remote directory, stack, shared networks, and the production-only VIF database name, role, network, and password; exact destinations are in `deploy/credential-inventory.json` | +| `Brio Staging - PostgreSQL` | `canary`; Keycloak passwords only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, and `BRIO_STAGING_BACKUP_DB_PASSWORD`; only `KEYCLOAK_BRIO_STAGING_DB_PASSWORD` and `KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD` are mirrored to `staging-brio-identity-db` | | `Brio Staging - PKI and Backup Keys` | `canary`; recipient certificate only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CA_PEM`, `POSTGRES_SERVER_CERT_PEM`, `POSTGRES_SERVER_KEY_PEM`, and `BRIO_BACKUP_RECIPIENT_CERT_PEM`; only `BRIO_BACKUP_RECIPIENT_CERT_PEM` is mirrored to `staging-brio-identity-db` | | `PostgreSQL · Brio identity release orchestrator` | `release-brio-identity-db` | secret `KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN` | | `PostgreSQL · Keycloak cohort source reader` | `keycloak-cohort-restore` | secret `KEYCLOAK_COHORT_SOURCE_TOKEN` | @@ -244,11 +245,12 @@ GitHub environment variables. The exact Brio inventory is: | `PostgreSQL · JIT Launcher App` | repository policy only | public repository variable `POSTGRES_CI_LAUNCHER_APP_SENDER_ID`; private App fields remain on the controller host only | | `PostgreSQL · JIT hypervisor attestation` | repository policy only | public repository variables `POSTGRES_CI_ATTESTATION_PUBLIC_KEY` and `POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256`; the signing key remains on the hypervisor only | -The `canary`, `production`, `staging-brio-identity-db`, and -`keycloak-cohort-restore` environments also hold reviewed non-secret constants -such as `DEPLOY_REMOTE_DIR`, stack/network names, -`BRIO_IDENTITY_DB_HOSTNAME`, and `BRIO_KEYCLOAK_DB_SOURCE_CIDR`. Store them as -environment variables, not duplicated password-vault secrets. +The identity DB hostname and Keycloak source CIDR are protected environment +variables. The current shared-Swarm workflow still consumes its remote path, +stack, network, VIF database name, and VIF role through the existing protected +secret namespace; the machine-readable inventory preserves that exact +workflow contract until a coordinated workflow/provider migration. None of +these values may be copied to repository secrets. The machine-readable mapping and fail-closed operator procedure are documented in [`docs/credential-sync.md`](docs/credential-sync.md). Use `pass-cli` from an diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 4564ab2..c4ecdb8 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -766,7 +766,8 @@ require( require(all(entry.get("boundary") in {"host-root-file", "host-root-setting", "operator-stdin", "operator-verification"} for entry in non_github_entries), "Non-GitHub credential boundary is invalid.") for canonical_item in { "Hetzner Database Server makepad", "Brio Staging - PostgreSQL", - pki_item, "PostgreSQL · Brio identity release orchestrator", + "PostgreSQL · shared Swarm deployment", pki_item, + "PostgreSQL · Brio identity release orchestrator", "PostgreSQL · Keycloak cohort source reader", "Makepad Docker Hardened Images", "PostgreSQL · PR Checks App", "PostgreSQL · JIT Launcher App", "PostgreSQL · JIT hypervisor attestation", From 054e24b3de93faab3b63486e2367de7e8fbc9928 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 06:52:35 +0200 Subject: [PATCH 06/16] fix(credentials): target Postgres Swarm host --- README.md | 3 ++- deploy/credential-inventory.json | 20 ++++++++++---------- docs/credential-sync.md | 10 ++++++---- scripts/test-sync-github-environments.sh | 2 ++ scripts/validate-postgres-config.sh | 17 ++++++++++++++++- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 97bc74d..8fb025b 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,8 @@ GitHub environment variables. The exact Brio inventory is: | Canonical Proton Pass item | Protected GitHub environment | Exact mirrored fields | | --- | --- | --- | -| `Hetzner Database Server makepad` | `canary`, `production`, `staging-brio-identity-db`, and `keycloak-cohort-restore` | canonical SSH fields `DEPLOY_SSH_HOST`, `DEPLOY_SSH_PORT`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_PRIVATE_KEY`, `DEPLOY_SSH_KNOWN_HOSTS`; mirror the same reviewed values under the workflow aliases `BRIO_IDENTITY_DB_DEPLOY_SSH_HOST`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PORT`, `BRIO_IDENTITY_DB_DEPLOY_SSH_USER`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY`, `BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS`, `KEYCLOAK_COHORT_DB_SSH_HOST`, `KEYCLOAK_COHORT_DB_SSH_PORT`, `KEYCLOAK_COHORT_DB_SSH_USER`, `KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY`, and `KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS` only in their named environments | +| `Hetzner App Server makepad` | `canary` and `production` | canonical SSH fields `DEPLOY_SSH_HOST`, `DEPLOY_SSH_PORT`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_PRIVATE_KEY`, and `DEPLOY_SSH_KNOWN_HOSTS` for the application Swarm manager required by `manual-deploy.yml` | +| `Hetzner Database Server makepad` | `staging-brio-identity-db` and `keycloak-cohort-restore` | the standalone DB host SSH fields mirrored under the workflow aliases `BRIO_IDENTITY_DB_DEPLOY_SSH_HOST`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PORT`, `BRIO_IDENTITY_DB_DEPLOY_SSH_USER`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY`, `BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS`, `KEYCLOAK_COHORT_DB_SSH_HOST`, `KEYCLOAK_COHORT_DB_SSH_PORT`, `KEYCLOAK_COHORT_DB_SSH_USER`, `KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY`, and `KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS` only in their named environments | | `PostgreSQL · shared Swarm deployment` | `canary` and `production` | current workflow-compatible protected fields for remote directory, stack, shared networks, and the production-only VIF database name, role, network, and password; exact destinations are in `deploy/credential-inventory.json` | | `Brio Staging - PostgreSQL` | `canary`; Keycloak passwords only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, and `BRIO_STAGING_BACKUP_DB_PASSWORD`; only `KEYCLOAK_BRIO_STAGING_DB_PASSWORD` and `KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD` are mirrored to `staging-brio-identity-db` | | `Brio Staging - PKI and Backup Keys` | `canary`; recipient certificate only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CA_PEM`, `POSTGRES_SERVER_CERT_PEM`, `POSTGRES_SERVER_KEY_PEM`, and `BRIO_BACKUP_RECIPIENT_CERT_PEM`; only `BRIO_BACKUP_RECIPIENT_CERT_PEM` is mirrored to `staging-brio-identity-db` | diff --git a/deploy/credential-inventory.json b/deploy/credential-inventory.json index f155f85..5ebbff9 100644 --- a/deploy/credential-inventory.json +++ b/deploy/credential-inventory.json @@ -3,11 +3,11 @@ "repository": "Makepad-fr/postgres", "vault": "Makepad", "githubEntries": [ - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_HOST"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_PORT"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_USER"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_REMOTE_DIR","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_REMOTE_DIR"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_STACK_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_STACK_NAME"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_CATWLK_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_CATWLK_DB_NETWORK"}, @@ -21,11 +21,11 @@ {"environment":"canary","kind":"secret","requirement":"required","destination":"POSTGRES_SERVER_KEY_PEM","item":"Brio Staging - PKI and Backup Keys","field":"POSTGRES_SERVER_KEY_PEM"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"BRIO_BACKUP_RECIPIENT_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"BRIO_BACKUP_RECIPIENT_CERT_PEM"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_HOST"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_PORT"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_USER"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_REMOTE_DIR","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_REMOTE_DIR"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_STACK_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_STACK_NAME"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_CATWLK_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_CATWLK_DB_NETWORK"}, diff --git a/docs/credential-sync.md b/docs/credential-sync.md index 04d0cc1..358f3ad 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -59,7 +59,7 @@ and CIDRs consumed as `vars.*` are environment variables. ### `canary` -- `Hetzner Database Server makepad/DEPLOY_SSH_*` maps to the five exact +- `Hetzner App Server makepad/DEPLOY_SSH_*` maps to the five exact `DEPLOY_SSH_*` secrets. - `PostgreSQL · shared Swarm deployment` maps the remote directory, stack, and three network fields used by the canary workflow. @@ -76,7 +76,7 @@ deleting them. ### `production` -- `Hetzner Database Server makepad/DEPLOY_SSH_*` maps to the five exact SSH +- `Hetzner App Server makepad/DEPLOY_SSH_*` maps to the five exact SSH secrets. - `PostgreSQL · shared Swarm deployment` maps the remote directory, stack, Catwlk/Le Petit Coin/VIF networks, VIF database and role names, and the VIF @@ -87,7 +87,8 @@ workflow and are deliberately absent from the reviewed inventory. ### `staging-brio-identity-db` -- The five canonical `DEPLOY_SSH_*` fields map to their +- `Hetzner Database Server makepad` supplies the five canonical + `DEPLOY_SSH_*` fields, which map to their `BRIO_IDENTITY_DB_DEPLOY_SSH_*` aliases. - `Brio Staging - PostgreSQL` maps only the Keycloak application and backup database passwords. @@ -105,7 +106,8 @@ PKI/backup-certificate field mirrored here. - `release-brio-identity-db` receives only the dedicated `KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN` secret. - `keycloak-cohort-restore` receives its read-only Keycloak source token, the - five DB-capture SSH aliases, and the DHI pull username/token. + five DB-capture SSH aliases from `Hetzner Database Server makepad`, and the + DHI pull username/token. - `postgres-ci-attestation` receives only the Checks App private key. The Launcher App private key and the Ed25519 signing key never enter Actions. diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index f4a5ada..b332c9f 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -184,6 +184,8 @@ assert_no_value_read_or_write() { # The machine-readable inventory encodes the exact workflow-backed PKI split. jq -e ' + ([.githubEntries[] | select((.environment == "canary" or .environment == "production") and (.destination | startswith("DEPLOY_SSH_"))) | .item] | unique) == ["Hetzner App Server makepad"] and + ([.githubEntries[] | select((.environment == "staging-brio-identity-db" or .environment == "keycloak-cohort-restore") and (.destination | test("SSH_(HOST|PORT|USER|PRIVATE_KEY|KNOWN_HOSTS)$"))) | .item] | unique) == ["Hetzner Database Server makepad"] and ([.githubEntries[] | select(.item == "Brio Staging - PKI and Backup Keys" and (.destination | endswith("_PEM"))) | [.environment, .destination]] | sort) == ([ ["canary", "BRIO_BACKUP_RECIPIENT_CERT_PEM"], diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index c4ecdb8..67882f0 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -765,7 +765,8 @@ require( ) require(all(entry.get("boundary") in {"host-root-file", "host-root-setting", "operator-stdin", "operator-verification"} for entry in non_github_entries), "Non-GitHub credential boundary is invalid.") for canonical_item in { - "Hetzner Database Server makepad", "Brio Staging - PostgreSQL", + "Hetzner App Server makepad", "Hetzner Database Server makepad", + "Brio Staging - PostgreSQL", "PostgreSQL · shared Swarm deployment", pki_item, "PostgreSQL · Brio identity release orchestrator", "PostgreSQL · Keycloak cohort source reader", "Makepad Docker Hardened Images", @@ -773,6 +774,20 @@ for canonical_item in { "PostgreSQL · JIT hypervisor attestation", }: require(canonical_item in readme, f"README credential inventory is missing canonical Proton item {canonical_item}.") +ssh_source_by_environment = { + environment: { + entry["item"] for entry in github_entries + if entry["environment"] == environment and entry["destination"].startswith("DEPLOY_SSH_") + } + for environment in ("canary", "production") +} +require( + ssh_source_by_environment == { + "canary": {"Hetzner App Server makepad"}, + "production": {"Hetzner App Server makepad"}, + }, + "Shared-Swarm deployment credentials must target the application Swarm host.", +) for required in ( "--sync requires one explicit --environment", "pass-cli item list", From 4568e34ef61fea4cab5dca96fa89abc9c958c268 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 06:59:54 +0200 Subject: [PATCH 07/16] fix(credentials): use native SSH source fields --- README.md | 4 +-- deploy/credential-inventory.json | 40 ++++++++++++------------ docs/credential-sync.md | 12 +++---- scripts/test-sync-github-environments.sh | 17 ++++++++++ scripts/validate-postgres-config.sh | 13 ++++++++ 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8fb025b..52e7483 100644 --- a/README.md +++ b/README.md @@ -234,8 +234,8 @@ GitHub environment variables. The exact Brio inventory is: | Canonical Proton Pass item | Protected GitHub environment | Exact mirrored fields | | --- | --- | --- | -| `Hetzner App Server makepad` | `canary` and `production` | canonical SSH fields `DEPLOY_SSH_HOST`, `DEPLOY_SSH_PORT`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_PRIVATE_KEY`, and `DEPLOY_SSH_KNOWN_HOSTS` for the application Swarm manager required by `manual-deploy.yml` | -| `Hetzner Database Server makepad` | `staging-brio-identity-db` and `keycloak-cohort-restore` | the standalone DB host SSH fields mirrored under the workflow aliases `BRIO_IDENTITY_DB_DEPLOY_SSH_HOST`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PORT`, `BRIO_IDENTITY_DB_DEPLOY_SSH_USER`, `BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY`, `BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS`, `KEYCLOAK_COHORT_DB_SSH_HOST`, `KEYCLOAK_COHORT_DB_SSH_PORT`, `KEYCLOAK_COHORT_DB_SSH_USER`, `KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY`, and `KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS` only in their named environments | +| `Hetzner App Server makepad` | `canary` and `production` | native fields `host`, `port`, `user`, `private_key`, and `known_hosts` map to the five `DEPLOY_SSH_*` destinations for the application Swarm manager required by `manual-deploy.yml` | +| `Hetzner Database Server makepad` | `staging-brio-identity-db` and `keycloak-cohort-restore` | the same five native SSH fields map to the workflow aliases `BRIO_IDENTITY_DB_DEPLOY_SSH_*` and `KEYCLOAK_COHORT_DB_SSH_*` only in their named standalone DB-host environments | | `PostgreSQL · shared Swarm deployment` | `canary` and `production` | current workflow-compatible protected fields for remote directory, stack, shared networks, and the production-only VIF database name, role, network, and password; exact destinations are in `deploy/credential-inventory.json` | | `Brio Staging - PostgreSQL` | `canary`; Keycloak passwords only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, and `BRIO_STAGING_BACKUP_DB_PASSWORD`; only `KEYCLOAK_BRIO_STAGING_DB_PASSWORD` and `KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD` are mirrored to `staging-brio-identity-db` | | `Brio Staging - PKI and Backup Keys` | `canary`; recipient certificate only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CA_PEM`, `POSTGRES_SERVER_CERT_PEM`, `POSTGRES_SERVER_KEY_PEM`, and `BRIO_BACKUP_RECIPIENT_CERT_PEM`; only `BRIO_BACKUP_RECIPIENT_CERT_PEM` is mirrored to `staging-brio-identity-db` | diff --git a/deploy/credential-inventory.json b/deploy/credential-inventory.json index 5ebbff9..f3fbdb1 100644 --- a/deploy/credential-inventory.json +++ b/deploy/credential-inventory.json @@ -3,11 +3,11 @@ "repository": "Makepad-fr/postgres", "vault": "Makepad", "githubEntries": [ - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_HOST"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_PORT"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_USER"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner App Server makepad","field":"host"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner App Server makepad","field":"port"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner App Server makepad","field":"user"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner App Server makepad","field":"private_key"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner App Server makepad","field":"known_hosts"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_REMOTE_DIR","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_REMOTE_DIR"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_STACK_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_STACK_NAME"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_CATWLK_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_CATWLK_DB_NETWORK"}, @@ -21,11 +21,11 @@ {"environment":"canary","kind":"secret","requirement":"required","destination":"POSTGRES_SERVER_KEY_PEM","item":"Brio Staging - PKI and Backup Keys","field":"POSTGRES_SERVER_KEY_PEM"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"BRIO_BACKUP_RECIPIENT_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"BRIO_BACKUP_RECIPIENT_CERT_PEM"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_HOST"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_PORT"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_USER"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner App Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner App Server makepad","field":"host"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner App Server makepad","field":"port"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_USER","item":"Hetzner App Server makepad","field":"user"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner App Server makepad","field":"private_key"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner App Server makepad","field":"known_hosts"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_REMOTE_DIR","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_REMOTE_DIR"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_STACK_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_STACK_NAME"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_CATWLK_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_CATWLK_DB_NETWORK"}, @@ -35,11 +35,11 @@ {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_USER","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_USER"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_PASSWORD","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_PASSWORD"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"host"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"port"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"user"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"private_key"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"known_hosts"}, {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_BRIO_STAGING_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"KEYCLOAK_BRIO_STAGING_DB_PASSWORD"}, {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD"}, {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_BACKUP_RECIPIENT_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"BRIO_BACKUP_RECIPIENT_CERT_PEM"}, @@ -49,11 +49,11 @@ {"environment":"release-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN","item":"PostgreSQL · Brio identity release orchestrator","field":"KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN"}, {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_SOURCE_TOKEN","item":"PostgreSQL · Keycloak cohort source reader","field":"KEYCLOAK_COHORT_SOURCE_TOKEN"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_HOST","item":"Hetzner Database Server makepad","field":"host"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PORT","item":"Hetzner Database Server makepad","field":"port"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_USER","item":"Hetzner Database Server makepad","field":"user"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"private_key"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"known_hosts"}, {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"DHI_REGISTRY_USERNAME","item":"Makepad Docker Hardened Images","field":"DOCKERHUB_USERNAME"}, {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"DHI_REGISTRY_PASSWORD","item":"Makepad Docker Hardened Images","field":"DOCKERHUB_PRO_PAT"}, diff --git a/docs/credential-sync.md b/docs/credential-sync.md index 358f3ad..011f539 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -59,8 +59,8 @@ and CIDRs consumed as `vars.*` are environment variables. ### `canary` -- `Hetzner App Server makepad/DEPLOY_SSH_*` maps to the five exact - `DEPLOY_SSH_*` secrets. +- `Hetzner App Server makepad` fields `host`, `port`, `user`, `private_key`, + and `known_hosts` map to the five exact `DEPLOY_SSH_*` secrets. - `PostgreSQL · shared Swarm deployment` maps the remote directory, stack, and three network fields used by the canary workflow. - `Brio Staging - PostgreSQL` maps only @@ -76,8 +76,8 @@ deleting them. ### `production` -- `Hetzner App Server makepad/DEPLOY_SSH_*` maps to the five exact SSH - secrets. +- The same five native `Hetzner App Server makepad` fields map to the exact + `DEPLOY_SSH_*` secrets. - `PostgreSQL · shared Swarm deployment` maps the remote directory, stack, Catwlk/Le Petit Coin/VIF networks, VIF database and role names, and the VIF password consumed by the workflow. @@ -87,8 +87,8 @@ workflow and are deliberately absent from the reviewed inventory. ### `staging-brio-identity-db` -- `Hetzner Database Server makepad` supplies the five canonical - `DEPLOY_SSH_*` fields, which map to their +- `Hetzner Database Server makepad` supplies native fields `host`, `port`, + `user`, `private_key`, and `known_hosts`, which map to their `BRIO_IDENTITY_DB_DEPLOY_SSH_*` aliases. - `Brio Staging - PostgreSQL` maps only the Keycloak application and backup database passwords. diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index b332c9f..fec453e 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -186,6 +186,23 @@ assert_no_value_read_or_write() { jq -e ' ([.githubEntries[] | select((.environment == "canary" or .environment == "production") and (.destination | startswith("DEPLOY_SSH_"))) | .item] | unique) == ["Hetzner App Server makepad"] and ([.githubEntries[] | select((.environment == "staging-brio-identity-db" or .environment == "keycloak-cohort-restore") and (.destination | test("SSH_(HOST|PORT|USER|PRIVATE_KEY|KNOWN_HOSTS)$"))) | .item] | unique) == ["Hetzner Database Server makepad"] and + ([.githubEntries[] | select(.item == "Hetzner App Server makepad" or .item == "Hetzner Database Server makepad") | {destination, field}] | unique | sort_by(.destination)) == ([ + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_HOST","field":"host"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS","field":"known_hosts"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PORT","field":"port"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY","field":"private_key"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_USER","field":"user"}, + {"destination":"DEPLOY_SSH_HOST","field":"host"}, + {"destination":"DEPLOY_SSH_KNOWN_HOSTS","field":"known_hosts"}, + {"destination":"DEPLOY_SSH_PORT","field":"port"}, + {"destination":"DEPLOY_SSH_PRIVATE_KEY","field":"private_key"}, + {"destination":"DEPLOY_SSH_USER","field":"user"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_HOST","field":"host"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS","field":"known_hosts"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_PORT","field":"port"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY","field":"private_key"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_USER","field":"user"} + ] | sort_by(.destination)) and ([.githubEntries[] | select(.item == "Brio Staging - PKI and Backup Keys" and (.destination | endswith("_PEM"))) | [.environment, .destination]] | sort) == ([ ["canary", "BRIO_BACKUP_RECIPIENT_CERT_PEM"], diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 67882f0..5ab5c85 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -788,6 +788,19 @@ require( }, "Shared-Swarm deployment credentials must target the application Swarm host.", ) +native_ssh_field_by_suffix = { + "HOST": "host", "PORT": "port", "USER": "user", + "PRIVATE_KEY": "private_key", "KNOWN_HOSTS": "known_hosts", +} +for entry in github_entries: + if entry.get("item") not in {"Hetzner App Server makepad", "Hetzner Database Server makepad"}: + continue + suffix = next((suffix for suffix in native_ssh_field_by_suffix if entry["destination"].endswith(f"SSH_{suffix}")), None) + require(suffix is not None, f"Unexpected SSH destination {entry['destination']}.") + require( + entry.get("field") == native_ssh_field_by_suffix[suffix], + f"SSH destination {entry['destination']} must use its native Proton source field.", + ) for required in ( "--sync requires one explicit --environment", "pass-cli item list", From 6443720f0ec20920d2b9fae51ce31e274d93f946 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 07:09:37 +0200 Subject: [PATCH 08/16] fix(credentials): reconcile repository trust anchors --- README.md | 14 +- docs/credential-sync.md | 22 +- scripts/run-ci.sh | 1 + scripts/sync-github-environments.sh | 214 ++++++++++++++++---- scripts/test-sync-github-environments.sh | 99 ++++++++- scripts/validate-postgres-config.sh | 16 ++ scripts/validate-repository-trust-anchor.py | 78 +++++++ 7 files changed, 388 insertions(+), 56 deletions(-) create mode 100644 scripts/validate-repository-trust-anchor.py diff --git a/README.md b/README.md index 52e7483..7e20ab4 100644 --- a/README.md +++ b/README.md @@ -264,9 +264,17 @@ pass-cli item view --item-title '' --field '' \ ``` Never place values in command arguments, temporary files, shell history, -Actions logs, or issue text. Mirror non-secret variables with the same reviewed -reconciliation session, compare their GitHub read-back, and record only item -IDs, field names, timestamps, and non-secret fingerprints in the deployment +Actions logs, or issue text. Mirror the four non-secret repository trust +anchors only through the explicitly bounded command below; it validates their +provider IDs, lowercase SHA-256 digest, and Ed25519 public key before stdin-only +writes, then compares two GitHub read-backs exactly with Proton: + +```bash +./scripts/sync-github-environments.sh --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables +``` + +Record only item IDs, field names, timestamps, and non-secret fingerprints in the deployment change record. Every listed environment, including `production`, must have exactly one custom branch deployment policy whose type is `branch` and whose name is exactly `main`; GitHub's generic "protected branches" option is not an diff --git a/docs/credential-sync.md b/docs/credential-sync.md index 011f539..829b373 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -31,6 +31,14 @@ environment: ./scripts/sync-github-environments.sh --sync --environment postgres-ci-attestation ``` +Reconcile the four public repository trust anchors as one separately bounded +operation: + +```sh +./scripts/sync-github-environments.sh --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables +``` + Sync mode rejects an omitted or arbitrary environment. Before its first field read it proves this intentionally public repository is active and uses `main` as its default branch; proves the selected environment has exactly one custom @@ -45,7 +53,13 @@ disabled, and values never enter arguments, exported child environments, logs, or files. The helper never creates an environment, changes a branch policy, modifies -Proton Pass, sets repository-level values, or deletes a GitHub name. A legacy +Proton Pass, or deletes a GitHub name. Its ordinary `--sync --environment` +mode never sets repository-level values. The explicit repository-variable mode +reads all four reviewed fields before its first write, validates GitHub IDs as +canonical positive decimals, the base-image digest as lowercase SHA-256, and +the attestation key as canonical Ed25519 SubjectPublicKeyInfo PEM. It streams +each value to GitHub over standard input and compares two provider read-backs +byte-for-byte with the in-memory Proton value. A legacy name must be removed manually only after its consumer has migrated and the approved replacement has been read back. Exit `0` means the reviewed names and protection are complete, exit `1` means a required source/destination or policy @@ -119,8 +133,10 @@ that immutable bot ID is a repository variable. The related `POSTGRES_PR_CHECK_APP_ID`, `POSTGRES_CI_ATTESTATION_PUBLIC_KEY`, and `POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256` are also public policy inputs and remain repository variables. They are identifiers, a public key, and a digest, -not credentials. The generic helper audits their exact names and canonical -Proton item titles but does not overwrite repository variables. +not credentials. Check mode audits their exact names and canonical Proton item +titles without reading values. Only the explicitly confirmed +`--sync-repository-variables` operation may overwrite them, and success requires +semantic validation plus exact value read-back for all four anchors. Repository-level secrets are forbidden. Their exposure boundary would include workflows that have not passed a protected environment gate. Any repository diff --git a/scripts/run-ci.sh b/scripts/run-ci.sh index dd34d1e..12bc9c5 100755 --- a/scripts/run-ci.sh +++ b/scripts/run-ci.sh @@ -50,6 +50,7 @@ for source in ( "scripts/verify-postgres-ci-jit-result.py", "scripts/reconcile-github-environment-main-policy.py", "scripts/test-github-environment-main-policy.py", + "scripts/validate-repository-trust-anchor.py", ): ast.parse(Path(source).read_text(), filename=source) PY diff --git a/scripts/sync-github-environments.sh b/scripts/sync-github-environments.sh index 9368011..da0eb8d 100755 --- a/scripts/sync-github-environments.sh +++ b/scripts/sync-github-environments.sh @@ -16,14 +16,18 @@ readonly allowed_environments='canary production staging-brio-identity-db releas repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) readonly repo_root readonly inventory=${repo_root}/deploy/credential-inventory.json +readonly repository_anchor_validator=${repo_root}/scripts/validate-repository-trust-anchor.py readonly max_value_bytes=49152 usage() { printf '%s\n' \ 'usage: sync-github-environments.sh [--check|--sync] [--environment NAME]' \ + ' sync-github-environments.sh --sync-repository-variables --confirm Makepad-fr/postgres:repository-variables' \ '' \ ' --check Read names and policy only; never read Proton field values (default).' \ - ' --sync Preflight every selected field, then stream one environment to GitHub.' + ' --sync Preflight every selected field, then stream one environment to GitHub.' \ + ' --sync-repository-variables' \ + ' Reconcile exactly the four public CI trust anchors and verify exact read-back.' } die() { @@ -34,9 +38,10 @@ die() { mode=check mode_selected=0 selected_environment= +confirmation= while (( $# > 0 )); do case "$1" in - --check|--sync) + --check|--sync|--sync-repository-variables) (( mode_selected == 0 )) || die 'select exactly one mode' mode=${1#--} mode_selected=1 @@ -47,6 +52,12 @@ while (( $# > 0 )); do selected_environment=$2 shift ;; + --confirm) + (( $# >= 2 )) || die '--confirm requires a value' + [[ -z "${confirmation}" ]] || die '--confirm may be supplied only once' + confirmation=$2 + shift + ;; --help|-h) usage exit 0 @@ -66,11 +77,20 @@ esac if [[ "${mode}" == sync && -z "${selected_environment}" ]]; then die '--sync requires one explicit --environment to bound the write scope' fi +if [[ "${mode}" == sync-repository-variables ]]; then + [[ -z "${selected_environment}" ]] || die '--sync-repository-variables does not accept --environment' + [[ "${confirmation}" == "${repository}:repository-variables" ]] || \ + die '--sync-repository-variables requires --confirm Makepad-fr/postgres:repository-variables' +elif [[ -n "${confirmation}" ]]; then + die '--confirm is accepted only with --sync-repository-variables' +fi for command_name in pass-cli gh jq python3 sort grep awk mktemp find wc tr; do command -v "${command_name}" >/dev/null || die "${command_name} is required" done [[ -f "${inventory}" && ! -L "${inventory}" ]] || die 'credential inventory is missing or is a symbolic link' +[[ -f "${repository_anchor_validator}" && ! -L "${repository_anchor_validator}" ]] || \ + die 'repository trust-anchor validator is missing or is a symbolic link' tmp_base=${TMPDIR:-/tmp} [[ -d "${tmp_base}" && ! -L "${tmp_base}" ]] || die 'temporary directory base is unsafe' @@ -96,12 +116,20 @@ declare -a entry_item=() declare -a entry_field=() declare -a source_values=() declare -a source_available=() +declare -a repository_requirement=() +declare -a repository_destination=() +declare -a repository_item=() +declare -a repository_field=() +declare -a repository_source_values=() cleanup() { local index for index in "${!source_values[@]}"; do unset 'source_values[index]' done + for index in "${!repository_source_values[@]}"; do + unset 'repository_source_values[index]' + done if [[ -n "${status_root:-}" && "${status_root}" == "${tmp_base}/postgres-credential-sync."* && -d "${status_root}" && ! -L "${status_root}" ]]; then find "${status_root}" -depth -mindepth 1 -delete rmdir -- "${status_root}" @@ -110,7 +138,7 @@ cleanup() { trap cleanup EXIT trap 'exit 130' HUP INT TERM -python3 - "${inventory}" "${repository}" "${vault}" "${selected_environment}" \ +python3 - "${inventory}" "${repository}" "${vault}" "${selected_environment}" "${mode}" \ "${github_entries_file}" "${repository_entries_file}" "${non_github_entries_file}" "${selected_sources_file}" <<'PY' import json import pathlib @@ -121,10 +149,12 @@ path = pathlib.Path(sys.argv[1]) expected_repository = sys.argv[2] expected_vault = sys.argv[3] selected_environment = sys.argv[4] -github_output = pathlib.Path(sys.argv[5]) -repository_output = pathlib.Path(sys.argv[6]) -non_github_output = pathlib.Path(sys.argv[7]) -sources_output = pathlib.Path(sys.argv[8]) +mode = sys.argv[5] +github_output = pathlib.Path(sys.argv[6]) +repository_output = pathlib.Path(sys.argv[7]) +non_github_output = pathlib.Path(sys.argv[8]) +sources_output = pathlib.Path(sys.argv[9]) +repository_sync = mode == "sync-repository-variables" payload = json.loads(path.read_text(encoding="utf-8")) expected_top_level = { @@ -208,10 +238,13 @@ for offset, entry in enumerate(github_entries): environment_counts[environment] += 1 if item == "Brio Staging - PKI and Backup Keys" and destination.endswith("_PEM"): pki_destinations.add((environment, destination)) - if not selected_environment or environment == selected_environment: + if not selected_environment or environment == selected_environment or ( + repository_sync and environment == "postgres-ci-attestation" + ): github_lines.append("\t".join((environment, kind, requirement, destination, item, field))) - prior = selected_source_requirements.get(item) - selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" + if not repository_sync: + prior = selected_source_requirements.get(item) + selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" if any(count == 0 for count in environment_counts.values()): raise SystemExit("every approved GitHub environment must have at least one inventory entry") @@ -241,7 +274,7 @@ for offset, entry in enumerate(repository_entries): raise SystemExit(f"duplicate repository variable: {destination}") seen_repository.add(destination) repository_lines.append("\t".join((requirement, destination, item, field))) - if not selected_environment or selected_environment == "postgres-ci-attestation": + if repository_sync or not selected_environment or selected_environment == "postgres-ci-attestation": prior = selected_source_requirements.get(item) selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" if seen_repository != expected_repository_variables: @@ -265,7 +298,7 @@ for offset, entry in enumerate(non_github_entries): raise SystemExit(f"duplicate non-GitHub destination: {boundary}/{destination}") seen_non_github.add(identity) non_github_lines.append("\t".join((boundary, requirement, destination, item, field))) - if not selected_environment: + if not selected_environment and not repository_sync: prior = selected_source_requirements.get(item) selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" @@ -290,6 +323,14 @@ while IFS=$'\t' read -r environment kind requirement destination item field; do entry_field[index]=${field} done <"${github_entries_file}" +while IFS=$'\t' read -r requirement destination item field; do + index=${#repository_destination[@]} + repository_requirement[index]=${requirement} + repository_destination[index]=${destination} + repository_item[index]=${item} + repository_field[index]=${field} +done <"${repository_entries_file}" + pass-cli test >/dev/null || die 'Proton Pass is not authenticated' GH_PROMPT_DISABLED=1 gh auth status >/dev/null 2>&1 || die 'GitHub CLI is not authenticated' @@ -303,11 +344,19 @@ protection_errors=0 environment_selected() { local environment=$1 + if [[ "${mode}" == sync-repository-variables ]]; then + [[ "${environment}" == postgres-ci-attestation ]] + return + fi [[ -z "${selected_environment}" || "${selected_environment}" == "${environment}" ]] } +environment_destinations_in_scope() { + [[ "${mode}" != sync-repository-variables ]] +} + repository_variables_in_scope() { - [[ -z "${selected_environment}" || "${selected_environment}" == postgres-ci-attestation ]] + [[ "${mode}" == sync-repository-variables || -z "${selected_environment}" || "${selected_environment}" == postgres-ci-attestation ]] } destination_expected() { @@ -420,41 +469,45 @@ report_status() { fi done <"${selected_sources_file}" - for index in "${!entry_environment[@]}"; do - environment=${entry_environment[index]} - kind=${entry_kind[index]} - requirement=${entry_requirement[index]} - destination=${entry_destination[index]} - destination_file=${status_root}/github-environment-${environment}-${kind}.txt - if [[ -f "${destination_file}" ]] && grep -Fqx -- "${destination}" "${destination_file}"; then - status=present - else - status=missing - if [[ "${requirement}" == required ]]; then - ((missing_required_destinations += 1)) + if environment_destinations_in_scope; then + for index in "${!entry_environment[@]}"; do + environment=${entry_environment[index]} + kind=${entry_kind[index]} + requirement=${entry_requirement[index]} + destination=${entry_destination[index]} + destination_file=${status_root}/github-environment-${environment}-${kind}.txt + if [[ -f "${destination_file}" ]] && grep -Fqx -- "${destination}" "${destination_file}"; then + status=present else - ((missing_optional_destinations += 1)) + status=missing + if [[ "${requirement}" == required ]]; then + ((missing_required_destinations += 1)) + else + ((missing_optional_destinations += 1)) + fi fi - fi - printf 'DESTINATION environment=%s kind=%s name=%s requirement=%s status=%s\n' \ - "${environment}" "${kind}" "${destination}" "${requirement}" "${status}" - done + printf 'DESTINATION environment=%s kind=%s name=%s requirement=%s status=%s\n' \ + "${environment}" "${kind}" "${destination}" "${requirement}" "${status}" + done + fi - for environment in ${allowed_environments}; do - environment_selected "${environment}" || continue - for kind in secret variable; do - destination_file=${status_root}/github-environment-${environment}-${kind}.txt - [[ -f "${destination_file}" ]] || continue - while IFS= read -r actual_name; do - [[ -n "${actual_name}" ]] || continue - if ! destination_expected "${environment}" "${kind}" "${actual_name}"; then - printf 'UNEXPECTED_DESTINATION scope=environment environment=%s kind=%s name=%s status=legacy-or-unmanaged\n' \ - "${environment}" "${kind}" "${actual_name}" - ((unexpected_destinations += 1)) - fi - done <"${destination_file}" + if environment_destinations_in_scope; then + for environment in ${allowed_environments}; do + environment_selected "${environment}" || continue + for kind in secret variable; do + destination_file=${status_root}/github-environment-${environment}-${kind}.txt + [[ -f "${destination_file}" ]] || continue + while IFS= read -r actual_name; do + [[ -n "${actual_name}" ]] || continue + if ! destination_expected "${environment}" "${kind}" "${actual_name}"; then + printf 'UNEXPECTED_DESTINATION scope=environment environment=%s kind=%s name=%s status=legacy-or-unmanaged\n' \ + "${environment}" "${kind}" "${actual_name}" + ((unexpected_destinations += 1)) + fi + done <"${destination_file}" + done done - done + fi if repository_variables_in_scope; then while IFS=$'\t' read -r requirement destination item field; do @@ -517,9 +570,80 @@ fi (( protection_errors == 0 )) || die 'refusing to sync into an invalid repository or environment policy' (( unexpected_destinations == 0 )) || die 'refusing to sync while forbidden, legacy, or unmanaged GitHub names remain' (( missing_required_sources == 0 )) || die 'required Proton Pass source items are missing or ambiguous' -(( missing_required_repository_variables == 0 )) || die 'required public repository policy variables must be reconciled separately before sync' +if [[ "${mode}" != sync-repository-variables ]]; then + (( missing_required_repository_variables == 0 )) || die 'required public repository policy variables must be reconciled separately before sync' +fi ulimit -c 0 || die 'could not disable process core dumps before handling credential values' +if [[ "${mode}" == sync-repository-variables ]]; then + # Read and semantically validate every public trust anchor before the first + # provider write. Values travel to the validator and GitHub only on stdin. + for index in "${!repository_destination[@]}"; do + item=${repository_item[index]} + field=${repository_field[index]} + destination=${repository_destination[index]} + requirement=${repository_requirement[index]} + value= + if ! value=$(pass-cli item view --vault-name "${vault}" --item-title "${item}" --field "${field}" 2>/dev/null); then + [[ "${requirement}" == optional ]] && continue + die "required Proton repository-variable field is missing or unreadable: ${item}/${field}" + fi + [[ -n "${value}" ]] || die "required Proton repository-variable field is empty: ${item}/${field}" + (( ${#value} <= max_value_bytes )) || die "Proton repository-variable field exceeds the bounded value size: ${item}/${field}" + if ! printf '%s' "${value}" | python3 "${repository_anchor_validator}" "${destination}" >/dev/null; then + unset value + die "Proton repository-variable field failed semantic validation: ${item}/${field}" + fi + repository_source_values[index]=${value} + unset value + printf 'SOURCE_FIELD item=%s field=%s requirement=%s status=validated\n' \ + "${item}" "${field}" "${requirement}" + done + + # Close the provider race without requiring the values to pre-exist. + load_names_and_policy + report_status + (( protection_errors == 0 )) || die 'repository or attestation-environment protection changed during source preflight' + (( unexpected_destinations == 0 )) || die 'a forbidden or unmanaged GitHub name appeared during source preflight' + (( missing_required_sources == 0 )) || die 'a required Proton item disappeared during source preflight' + + for index in "${!repository_destination[@]}"; do + destination=${repository_destination[index]} + [[ -n "${repository_source_values[index]:-}" ]] || continue + if ! printf '%s' "${repository_source_values[index]}" | + GH_PROMPT_DISABLED=1 gh variable set "${destination}" --repo "${repository}" >/dev/null 2>&1; then + die "GitHub rejected repository/variable/${destination}" + fi + readback= + if ! readback=$(GH_PROMPT_DISABLED=1 gh variable get "${destination}" --repo "${repository}" --json value --jq '.value' 2>/dev/null); then + die "GitHub repository-variable read-back failed: ${destination}" + fi + [[ "${readback}" == "${repository_source_values[index]}" ]] || \ + die "GitHub repository-variable read-back differed from Proton: ${destination}" + unset readback + printf 'SYNCED scope=repository kind=variable name=%s readback=exact\n' "${destination}" + done + + load_names_and_policy + report_status + (( protection_errors == 0 && missing_required_repository_variables == 0 && unexpected_destinations == 0 )) || \ + die 'GitHub repository-variable name or policy read-back did not match the reviewed inventory' + for index in "${!repository_destination[@]}"; do + destination=${repository_destination[index]} + [[ -n "${repository_source_values[index]:-}" ]] || continue + readback= + if ! readback=$(GH_PROMPT_DISABLED=1 gh variable get "${destination}" --repo "${repository}" --json value --jq '.value' 2>/dev/null); then + die "GitHub final repository-variable read-back failed: ${destination}" + fi + [[ "${readback}" == "${repository_source_values[index]}" ]] || \ + die "GitHub final repository-variable read-back differed from Proton: ${destination}" + unset readback 'repository_source_values[index]' + done + printf 'SYNC_COMPLETE repository=%s vault=%s scope=repository-variables count=%d\n' \ + "${repository}" "${vault}" "${#repository_destination[@]}" + exit 0 +fi + # Complete every selected source read before the first destination write. for index in "${!entry_environment[@]}"; do item=${entry_item[index]} diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index fec453e..492fd69 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -11,7 +11,8 @@ test_root=$(mktemp -d "${TMPDIR:-/tmp}/postgres-credential-sync-test.XXXXXXXX") readonly test_root readonly fake_bin=${test_root}/bin readonly audit_log=${test_root}/audit.log -install -d -m 0700 "${fake_bin}" +readonly github_state=${test_root}/github-state +install -d -m 0700 "${fake_bin}" "${github_state}" cleanup() { if [[ "${test_root}" == "${TMPDIR:-/tmp}/postgres-credential-sync-test."* && -d "${test_root}" && ! -L "${test_root}" ]]; then @@ -53,7 +54,22 @@ if [[ "${1:-} ${2:-}" == 'item view' ]]; then printf '%050000d' 0 exit 0 fi - printf 'HIGHLY_SECRET_%s' "${field}" + if [[ "${field}" == "${FAKE_INVALID_ANCHOR_FIELD:-}" ]]; then + printf 'syntactically-invalid-anchor' + exit 0 + fi + case "${field}" in + bot_user_id) printf '9001' ;; + qcow2_sha256) printf '%064d' 0 | tr 0 a ;; + ed25519_public_key) + printf '%s\n' \ + '-----BEGIN PUBLIC KEY-----' \ + 'MCowBQYDK2VwAyEAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=' \ + '-----END PUBLIC KEY-----' + ;; + app_id) printf '7001' ;; + *) printf 'HIGHLY_SECRET_%s' "${field}" ;; + esac exit 0 fi if [[ "${1:-}" == test ]]; then @@ -116,7 +132,7 @@ fi shift 2 environment= destination= -if [[ "${operation}" == set ]]; then +if [[ "${operation}" == set || "${operation}" == get ]]; then destination=${1:-} shift fi @@ -153,7 +169,28 @@ if [[ "${operation}" == list ]]; then exit 0 fi -[[ "${operation}" == set && -n "${environment}" && -n "${destination}" ]] +if [[ "${operation}" == get && "${kind}" == variable && -z "${environment}" && -n "${destination}" ]]; then + [[ -f "${FAKE_GITHUB_STATE_DIR}/${destination}" ]] + if [[ "${FAKE_READBACK_MISMATCH:-}" == "${destination}" ]]; then + printf 'different-readback' + else + command cat "${FAKE_GITHUB_STATE_DIR}/${destination}" + fi + exit 0 +fi + +[[ "${operation}" == set && -n "${destination}" ]] +if [[ -z "${environment}" ]]; then + [[ "${kind}" == variable ]] + umask 077 + command cat >"${FAKE_GITHUB_STATE_DIR}/${destination}" + bytes=$(wc -c <"${FAKE_GITHUB_STATE_DIR}/${destination}" | tr -d '[:space:]') + [[ "${bytes}" =~ ^[1-9][0-9]*$ ]] + printf 'gh-set scope=repository kind=%s name=%s bytes=%s\n' \ + "${kind}" "${destination}" "${bytes}" >>"${FAKE_AUDIT_LOG}" + exit 0 +fi + bytes=$(wc -c | tr -d '[:space:]') [[ "${bytes}" =~ ^[1-9][0-9]*$ ]] printf 'gh-set environment=%s kind=%s name=%s bytes=%s\n' \ @@ -166,7 +203,8 @@ run_helper() { local expected_status=$1 shift set +e - output=$(PATH="${fake_bin}:${PATH}" FAKE_INVENTORY="${inventory}" FAKE_AUDIT_LOG="${audit_log}" "$@" 2>&1) + output=$(PATH="${fake_bin}:${PATH}" FAKE_INVENTORY="${inventory}" FAKE_AUDIT_LOG="${audit_log}" \ + FAKE_GITHUB_STATE_DIR="${github_state}" "$@" 2>&1) status=$? set -e if (( status != expected_status )); then @@ -236,6 +274,13 @@ run_helper 1 "${helper}" --sync grep -Fq -- '--sync requires one explicit --environment' <<<"${output}" run_helper 1 "${helper}" --check --environment arbitrary-environment grep -Fq 'environment is not in the immutable PostgreSQL inventory' <<<"${output}" +run_helper 1 "${helper}" --sync-repository-variables +grep -Fq -- '--sync-repository-variables requires --confirm' <<<"${output}" +run_helper 1 "${helper}" --sync-repository-variables --environment postgres-ci-attestation \ + --confirm Makepad-fr/postgres:repository-variables +grep -Fq -- '--sync-repository-variables does not accept --environment' <<<"${output}" +run_helper 1 "${helper}" --check --confirm Makepad-fr/postgres:repository-variables +grep -Fq -- '--confirm is accepted only with --sync-repository-variables' <<<"${output}" : >"${audit_log}" run_helper 1 env FAKE_MISSING_DESTINATION=KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN \ @@ -292,6 +337,48 @@ run_helper 1 env FAKE_MISSING_REPOSITORY_VARIABLE=POSTGRES_CI_ATTESTATION_PUBLIC grep -Fq 'required_repository_variable_missing=1' <<<"${output}" assert_no_value_read_or_write +for invalid_anchor_field in bot_user_id qcow2_sha256 ed25519_public_key app_id; do + : >"${audit_log}" + run_helper 1 env FAKE_INVALID_ANCHOR_FIELD="${invalid_anchor_field}" \ + "${helper}" --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables + grep -Fq 'failed semantic validation' <<<"${output}" + if grep -Fq 'gh-set ' "${audit_log}" || grep -Fq 'syntactically-invalid-anchor' <<<"${output}"; then + echo 'invalid repository trust anchor was written or printed' >&2 + exit 1 + fi +done + +: >"${audit_log}" +run_helper 1 env FAKE_MISSING_FIELD=qcow2_sha256 \ + "${helper}" --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables +grep -Fq 'required Proton repository-variable field is missing or unreadable' <<<"${output}" +if grep -Fq 'gh-set ' "${audit_log}"; then + echo 'incomplete repository trust-anchor preflight wrote provider state' >&2 + exit 1 +fi + +: >"${audit_log}" +run_helper 1 env FAKE_READBACK_MISMATCH=POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256 \ + "${helper}" --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables +grep -Fq 'repository-variable read-back differed from Proton' <<<"${output}" +if grep -Fq 'HIGHLY_SECRET_' <<<"${output}"; then + echo 'repository-variable read-back failure printed a credential' >&2 + exit 1 +fi + +: >"${audit_log}" +run_helper 0 "${helper}" --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables +grep -Fq 'SYNC_COMPLETE repository=Makepad-fr/postgres vault=Makepad scope=repository-variables count=4' <<<"${output}" +[[ $(grep -Fc 'gh-set scope=repository kind=variable' "${audit_log}") == 4 ]] +repository_last_source_read=$(grep -n 'pass-cli item view' "${audit_log}" | tail -n 1 | cut -d: -f1) +repository_first_write=$(grep -n 'gh-set scope=repository' "${audit_log}" | head -n 1 | cut -d: -f1) +[[ "${repository_last_source_read}" =~ ^[1-9][0-9]*$ && "${repository_first_write}" =~ ^[1-9][0-9]*$ ]] +(( repository_last_source_read < repository_first_write )) + : >"${audit_log}" run_helper 1 env FAKE_MISSING_FIELD=KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN \ "${helper}" --sync --environment release-brio-identity-db @@ -344,6 +431,8 @@ first_destination_write=$(grep -n 'gh-set ' "${audit_log}" | head -n 1 | cut -d: candidate_root=${test_root}/candidate install -d -m 0700 "${candidate_root}/scripts" "${candidate_root}/deploy" cp "${helper}" "${candidate_root}/scripts/sync-github-environments.sh" +cp "${repo_root}/scripts/validate-repository-trust-anchor.py" \ + "${candidate_root}/scripts/validate-repository-trust-anchor.py" chmod 0755 "${candidate_root}/scripts/sync-github-environments.sh" jq '(.githubEntries[] | select(.destination == "BRIO_IDENTITY_DB_HOSTNAME")).kind = "secret"' \ diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 5ab5c85..5cbf23d 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -107,6 +107,7 @@ environment_policy_reconciler = read_required_text(repo_root / "scripts/reconcil environment_policy_test = read_required_text(repo_root / "scripts/test-github-environment-main-policy.py", "GitHub environment policy test") credential_sync = read_required_text(repo_root / "scripts/sync-github-environments.sh", "credential sync helper") credential_sync_test = read_required_text(repo_root / "scripts/test-sync-github-environments.sh", "credential sync behavioral test") +repository_anchor_validator = read_required_text(repo_root / "scripts/validate-repository-trust-anchor.py", "repository trust-anchor validator") release_evidence_validator = read_required_text(repo_root / "scripts/verify-brio-release-evidence.py", "Brio release evidence validator") cohort_evidence_validator = read_required_text(repo_root / "scripts/verify-keycloak-cohort-evidence.py", "Keycloak cohort evidence validator") cohort_capture = read_required_text(repo_root / "scripts/capture-keycloak-cohort-backups.sh", "Keycloak cohort backup capture") @@ -803,10 +804,13 @@ for entry in github_entries: ) for required in ( "--sync requires one explicit --environment", + "--sync-repository-variables requires --confirm Makepad-fr/postgres:repository-variables", "pass-cli item list", "pass-cli item view", 'gh secret set "${destination}" --repo "${repository}" --env "${environment}"', 'gh variable set "${destination}" --repo "${repository}" --env "${environment}"', + 'gh variable set "${destination}" --repo "${repository}"', + 'gh variable get "${destination}" --repo "${repository}" --json value', "REPOSITORY name=%s policy=public-active-main", 'status=forbidden', 'branch_policies[0].name == "main"', @@ -817,6 +821,15 @@ for forbidden in ("gh secret delete", "gh variable delete", "pass-cli item delet require(forbidden not in credential_sync, f"Credential sync helper must never delete provider state: {forbidden}") require("if [[ \"${mode}\" == check ]]" in credential_sync, "Credential sync helper must branch before Proton field reads.") require(credential_sync.find('if [[ "${mode}" == check ]]') < credential_sync.find("pass-cli item view"), "Check mode must exit before any Proton field-value read.") +for required in ( + "POSTGRES_CI_LAUNCHER_APP_SENDER_ID", + "POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256", + "POSTGRES_CI_ATTESTATION_PUBLIC_KEY", + "POSTGRES_PR_CHECK_APP_ID", + "ED25519_SPKI_PREFIX", + "MAX_PROVIDER_INTEGER", +): + require(required in repository_anchor_validator, f"Repository trust-anchor validator is missing: {required}") for required in ( "assert_no_value_read_or_write", "FAKE_REPOSITORY_LEGACY_KIND=secret", @@ -826,6 +839,9 @@ for required in ( "FAKE_OVERSIZED_FIELD", "wrong public/secret classification", "PKI destinations do not match", + "FAKE_INVALID_ANCHOR_FIELD", + "FAKE_READBACK_MISMATCH", + "repository_last_source_read < repository_first_write", ): require(required in credential_sync_test, f"Credential sync behavioral test is missing adversarial case: {required}") require("./scripts/test-sync-github-environments.sh" in ci_runner, "CI must run the credential sync behavioral test.") diff --git a/scripts/validate-repository-trust-anchor.py b/scripts/validate-repository-trust-anchor.py new file mode 100644 index 0000000..973c038 --- /dev/null +++ b/scripts/validate-repository-trust-anchor.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Validate one public PostgreSQL CI trust anchor read from standard input.""" + +from __future__ import annotations + +import base64 +import binascii +import re +import sys +from typing import NoReturn + + +INTEGER_ANCHORS = { + "POSTGRES_CI_LAUNCHER_APP_SENDER_ID", + "POSTGRES_PR_CHECK_APP_ID", +} +EXPECTED_ANCHORS = INTEGER_ANCHORS | { + "POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256", + "POSTGRES_CI_ATTESTATION_PUBLIC_KEY", +} +ED25519_SPKI_PREFIX = bytes.fromhex("302a300506032b6570032100") +MAX_PROVIDER_INTEGER = 2**63 - 1 + + +def fail(message: str) -> NoReturn: + raise SystemExit(f"repository trust anchor is invalid: {message}") + + +def validate(name: str, value: bytes) -> None: + if name not in EXPECTED_ANCHORS: + fail("unsupported destination") + if not value or len(value) > 4096 or b"\x00" in value: + fail("value is empty, oversized, or contains NUL") + + try: + text = value.decode("ascii") + except UnicodeDecodeError: + fail("value is not ASCII") + + if name in INTEGER_ANCHORS: + if not re.fullmatch(r"[1-9][0-9]{0,18}", text): + fail("provider identifier is not canonical decimal") + if int(text) > MAX_PROVIDER_INTEGER: + fail("provider identifier exceeds the accepted range") + return + + if name == "POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256": + if not re.fullmatch(r"[0-9a-f]{64}", text): + fail("base-image digest is not canonical lowercase SHA-256") + return + + lines = text.splitlines() + if ( + len(lines) != 3 + or lines[0] != "-----BEGIN PUBLIC KEY-----" + or lines[2] != "-----END PUBLIC KEY-----" + or not re.fullmatch(r"[A-Za-z0-9+/]{59}=", lines[1]) + ): + fail("attestation key is not canonical single-line PEM") + try: + der = base64.b64decode(lines[1], validate=True) + except (binascii.Error, ValueError): + fail("attestation key PEM is not valid base64") + if len(der) != 44 or not der.startswith(ED25519_SPKI_PREFIX): + fail("attestation key is not an Ed25519 SubjectPublicKeyInfo") + if der[len(ED25519_SPKI_PREFIX) :] == bytes(32): + fail("attestation key contains an invalid all-zero public point") + + +def main() -> int: + if len(sys.argv) != 2: + fail("expected exactly one destination argument") + validate(sys.argv[1], sys.stdin.buffer.read()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2cef44226103cf3a265af2e7d33958fe83753e66 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 07:17:22 +0200 Subject: [PATCH 09/16] fix(security): enforce deployment reviewer matrix --- README.md | 19 +- docs/credential-sync.md | 11 +- ...econcile-github-environment-main-policy.py | 161 ++++++++++--- scripts/sync-github-environments.sh | 35 +-- .../test-github-environment-main-policy.py | 217 ++++++++++++++---- scripts/test-sync-github-environments.sh | 63 ++++- scripts/validate-postgres-config.sh | 24 +- 7 files changed, 410 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 7e20ab4..0f87145 100644 --- a/README.md +++ b/README.md @@ -288,16 +288,25 @@ Audit all six policies without changing provider state: python3 scripts/reconcile-github-environment-main-policy.py audit ``` +The policy matrix requires GitHub user `idilsaglam` (immutable user ID +`39597780`), prevents self-review, and uses a zero-minute wait timer for +`canary`, `production`, `staging-brio-identity-db`, +`release-brio-identity-db`, and `keycloak-cohort-restore`. The sole deliberate +exception is `postgres-ci-attestation`: its exact-main signed machine result +must not deadlock waiting for a human deployment approval, so it has no +reviewer and no wait timer. + Reconcile one environment only after reviewing its current protection rules. -The helper preserves the current wait timer and required reviewers, creates -the exact `main` branch rule before removing broader custom rules, uses bounded -fail-closed pagination, and verifies the provider read-back. Applying requires -an explicit repository/environment confirmation; for production use: +The helper snapshots the pinned reviewer identity, refuses unknown rules, +applies the exact matrix, verifies its immediate read-back, creates the exact +`main` branch rule before removing broader custom rules, and then verifies the +final policy and reviewer identity again. Applying requires an explicit +repository/environment confirmation; for production use: ```bash python3 scripts/reconcile-github-environment-main-policy.py apply \ --environment production \ - --confirm Makepad-fr/postgres:production:exact-main + --confirm Makepad-fr/postgres:production:protected-policy-v1 ``` Run this only from an administrator workstation whose `gh` session has diff --git a/docs/credential-sync.md b/docs/credential-sync.md index 829b373..701faa2 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -42,7 +42,8 @@ operation: Sync mode rejects an omitted or arbitrary environment. Before its first field read it proves this intentionally public repository is active and uses `main` as its default branch; proves the selected environment has exactly one custom -branch policy named and typed `main`; rejects every repository-level secret; +branch policy named and typed `main` and the exact reviewed reviewer, timer, +and self-review policy; rejects every repository-level secret; and rejects unlisted environment or repository names. Public forks are treated as untrusted: protected workflow and disposable-runner controls remain the execution boundary. The helper then reads every selected Proton field before @@ -124,6 +125,14 @@ PKI/backup-certificate field mirrored here. DHI pull username/token. - `postgres-ci-attestation` receives only the Checks App private key. The Launcher App private key and the Ed25519 signing key never enter Actions. + This machine-only result publisher is the deliberate reviewer exception: it + has no required reviewer, no self-review setting, and no wait timer so a + signed CI result cannot deadlock behind a human approval. + +All other inventory environments require the pinned `idilsaglam` GitHub user +(immutable ID `39597780`), `prevent_self_review=true`, and a zero-minute wait +timer. The policy reconciler validates that live reviewer identity before and +after a write and compares the complete provider read-back with this matrix. ## Public repository variables and forbidden repository secrets diff --git a/scripts/reconcile-github-environment-main-policy.py b/scripts/reconcile-github-environment-main-policy.py index 9da605c..7b373cf 100755 --- a/scripts/reconcile-github-environment-main-policy.py +++ b/scripts/reconcile-github-environment-main-policy.py @@ -20,6 +20,15 @@ "postgres-ci-attestation", ) MAX_POLICY_PAGES = 1000 +REVIEWER_LOGIN = "idilsaglam" +REVIEWER_ID = 39597780 +REVIEWER_TYPE = "User" +HUMAN_REVIEW_ENVIRONMENTS = frozenset(REQUIRED_ENVIRONMENTS) - {"postgres-ci-attestation"} +WAIT_TIMERS = {environment: 0 for environment in REQUIRED_ENVIRONMENTS} +ATTESTATION_EXCEPTION = ( + "postgres-ci-attestation is an exact-main machine attestor whose signed " + "repository_dispatch result must not wait for a human deployment approval" +) class PolicyError(RuntimeError): @@ -70,6 +79,12 @@ def get_environment(self, environment: str) -> dict[str, Any]: raise PolicyError(f"Invalid environment response for {environment}") return response + def get_reviewer(self) -> dict[str, Any]: + response = self.request("GET", f"users/{quote(REVIEWER_LOGIN, safe='')}") + if not isinstance(response, dict): + raise PolicyError("Invalid reviewer identity response") + return response + def put_environment(self, environment: str, payload: dict[str, Any]) -> None: self.request("PUT", _environment_path(environment), payload) @@ -133,52 +148,107 @@ def is_exact_main_policy(policies: list[dict[str, Any]]) -> bool: return len(policies) == 1 and policies[0].get("name") == "main" and policies[0].get("type") == "branch" -def build_preserving_update(environment: dict[str, Any]) -> dict[str, Any]: - rules = environment.get("protection_rules") +def reviewer_snapshot(client: GitHubClient, environment: str) -> dict[str, Any] | None: + if environment not in HUMAN_REVIEW_ENVIRONMENTS: + return None + reviewer = client.get_reviewer() + snapshot = { + "type": reviewer.get("type"), + "id": reviewer.get("id"), + "login": reviewer.get("login"), + } + expected = {"type": REVIEWER_TYPE, "id": REVIEWER_ID, "login": REVIEWER_LOGIN} + if snapshot != expected: + raise PolicyError("Required reviewer identity does not match the pinned GitHub snapshot") + return snapshot + + +def expected_protection(environment: str, reviewer: dict[str, Any] | None) -> dict[str, Any]: + if environment not in REQUIRED_ENVIRONMENTS: + raise PolicyError(f"Unsupported environment: {environment}") + requires_review = environment in HUMAN_REVIEW_ENVIRONMENTS + if requires_review != (reviewer is not None): + raise PolicyError(f"Reviewer snapshot presence is invalid for {environment}") + return { + "wait_timer": WAIT_TIMERS[environment], + "prevent_self_review": requires_review, + "reviewers": [] if reviewer is None else [reviewer], + } + + +def protection_snapshot(environment: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload.get("name") != environment: + raise PolicyError(f"Environment identity differs from requested environment {environment}") + rules = payload.get("protection_rules") if not isinstance(rules, list): - raise PolicyError("Environment protection rules are missing") + raise PolicyError(f"Protection rules are missing for {environment}") wait_timer = 0 - reviewer_entries: list[dict[str, Any]] = [] prevent_self_review = False + reviewers: list[dict[str, Any]] = [] seen_rule_types: set[str] = set() for rule in rules: if not isinstance(rule, dict) or not isinstance(rule.get("type"), str): - raise PolicyError("Environment contains an invalid protection rule") + raise PolicyError(f"Protection rule is invalid for {environment}") rule_type = rule["type"] if rule_type in seen_rule_types: - raise PolicyError(f"Environment contains duplicate {rule_type} protection rules") + raise PolicyError(f"Duplicate {rule_type} protection rule for {environment}") seen_rule_types.add(rule_type) if rule_type == "branch_policy": continue if rule_type == "wait_timer": candidate = rule.get("wait_timer") - if not isinstance(candidate, int) or not 0 <= candidate <= 43_200: - raise PolicyError("Environment wait timer is invalid") + if not isinstance(candidate, int) or not 1 <= candidate <= 43_200: + raise PolicyError(f"Wait timer is invalid for {environment}") wait_timer = candidate continue if rule_type != "required_reviewers": - raise PolicyError(f"Refusing to overwrite unsupported protection rule: {rule_type}") + raise PolicyError(f"Unsupported protection rule {rule_type} for {environment}") - candidate_prevent = rule.get("prevent_self_review", False) - if not isinstance(candidate_prevent, bool): - raise PolicyError("Environment self-review setting is invalid") + candidate_prevent = rule.get("prevent_self_review") + candidates = rule.get("reviewers") + if not isinstance(candidate_prevent, bool) or not isinstance(candidates, list) or not 1 <= len(candidates) <= 6: + raise PolicyError(f"Required-reviewer rule is invalid for {environment}") prevent_self_review = candidate_prevent - reviewers = rule.get("reviewers") - if not isinstance(reviewers, list) or not 1 <= len(reviewers) <= 6: - raise PolicyError("Environment required reviewers are invalid") - for entry in reviewers: - reviewer = entry.get("reviewer") if isinstance(entry, dict) else None - reviewer_type = entry.get("type") if isinstance(entry, dict) else None - reviewer_id = reviewer.get("id") if isinstance(reviewer, dict) else None - if reviewer_type not in {"User", "Team"} or not isinstance(reviewer_id, int) or reviewer_id <= 0: - raise PolicyError("Environment required reviewer is invalid") - reviewer_entries.append({"type": reviewer_type, "id": reviewer_id}) - + for entry in candidates: + nested = entry.get("reviewer") if isinstance(entry, dict) else None + if not isinstance(nested, dict): + raise PolicyError(f"Required reviewer is invalid for {environment}") + snapshot = { + "type": entry.get("type"), + "id": nested.get("id"), + "login": nested.get("login"), + } + if nested.get("type") != snapshot["type"]: + raise PolicyError(f"Nested reviewer identity is inconsistent for {environment}") + if ( + snapshot["type"] not in {"User", "Team"} + or not isinstance(snapshot["id"], int) + or snapshot["id"] <= 0 + or not isinstance(snapshot["login"], str) + or not snapshot["login"] + ): + raise PolicyError(f"Required reviewer identity is invalid for {environment}") + reviewers.append(snapshot) + + if "branch_policy" not in seen_rule_types: + raise PolicyError(f"Branch-policy protection rule is missing for {environment}") return { "wait_timer": wait_timer, "prevent_self_review": prevent_self_review, - "reviewers": reviewer_entries, + "reviewers": reviewers, + } + + +def build_expected_update(environment: str, reviewer: dict[str, Any] | None) -> dict[str, Any]: + expected = expected_protection(environment, reviewer) + return { + "wait_timer": expected["wait_timer"], + "prevent_self_review": expected["prevent_self_review"], + "reviewers": [ + {"type": candidate["type"], "id": candidate["id"]} + for candidate in expected["reviewers"] + ], "deployment_branch_policy": { "protected_branches": False, "custom_branch_policies": True, @@ -186,19 +256,41 @@ def build_preserving_update(environment: dict[str, Any]) -> dict[str, Any]: } -def audit_environment(client: GitHubClient, environment: str) -> None: - current = client.get_environment(environment) +def audit_protection( + environment: str, + current: dict[str, Any], + reviewer: dict[str, Any] | None, +) -> None: if not has_custom_policy_mode(current): raise PolicyError(f"{environment} does not use custom deployment branch policies") + actual = protection_snapshot(environment, current) + expected = expected_protection(environment, reviewer) + if actual != expected: + raise PolicyError(f"{environment} protection does not match the pinned reviewer matrix") + + +def audit_environment( + client: GitHubClient, + environment: str, + expected_reviewer: dict[str, Any] | None = None, +) -> None: + reviewer = reviewer_snapshot(client, environment) if expected_reviewer is None else expected_reviewer + current = client.get_environment(environment) + audit_protection(environment, current, reviewer) policies = client.list_policies(environment) if not is_exact_main_policy(policies): raise PolicyError(f"{environment} is not restricted to the exact branch main") def reconcile_environment(client: GitHubClient, environment: str) -> None: + initial_reviewer = reviewer_snapshot(client, environment) current = client.get_environment(environment) - if not has_custom_policy_mode(current): - client.put_environment(environment, build_preserving_update(current)) + # Parse before writing so unknown or malformed rules are never silently removed. + current_snapshot = protection_snapshot(environment, current) + expected = expected_protection(environment, initial_reviewer) + if not has_custom_policy_mode(current) or current_snapshot != expected: + client.put_environment(environment, build_expected_update(environment, initial_reviewer)) + audit_protection(environment, client.get_environment(environment), initial_reviewer) policies = client.list_policies(environment) exact_main = [policy for policy in policies if policy.get("name") == "main" and policy.get("type") == "branch"] @@ -213,7 +305,11 @@ def reconcile_environment(client: GitHubClient, environment: str) -> None: for policy in policies: if policy["id"] != keep_id: client.delete_policy(environment, policy["id"]) - audit_environment(client, environment) + + final_reviewer = reviewer_snapshot(client, environment) + if final_reviewer != initial_reviewer: + raise PolicyError("Required reviewer identity changed during reconciliation") + audit_environment(client, environment, initial_reviewer) def main() -> int: @@ -227,7 +323,7 @@ def main() -> int: if args.mode == "apply": if len(environments) != 1: parser.error("apply requires exactly one --environment") - expected_confirmation = f"{REPOSITORY}:{environments[0]}:exact-main" + expected_confirmation = f"{REPOSITORY}:{environments[0]}:protected-policy-v1" if args.confirm != expected_confirmation: parser.error(f"apply requires --confirm {expected_confirmation}") @@ -238,7 +334,10 @@ def main() -> int: reconcile_environment(client, environment) else: audit_environment(client, environment) - print(f"{environment}: exact custom branch policy main") + if environment == "postgres-ci-attestation": + print(f"{environment}: exact main, no timer, automated-attestor exception") + else: + print(f"{environment}: exact main, pinned reviewer, self-review denied, no timer") except PolicyError as error: print(f"Environment policy verification failed: {error}", file=__import__("sys").stderr) return 1 diff --git a/scripts/sync-github-environments.sh b/scripts/sync-github-environments.sh index da0eb8d..d0ca773 100755 --- a/scripts/sync-github-environments.sh +++ b/scripts/sync-github-environments.sh @@ -17,6 +17,7 @@ repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) readonly repo_root readonly inventory=${repo_root}/deploy/credential-inventory.json readonly repository_anchor_validator=${repo_root}/scripts/validate-repository-trust-anchor.py +readonly environment_policy_reconciler=${repo_root}/scripts/reconcile-github-environment-main-policy.py readonly max_value_bytes=49152 usage() { @@ -91,6 +92,8 @@ done [[ -f "${inventory}" && ! -L "${inventory}" ]] || die 'credential inventory is missing or is a symbolic link' [[ -f "${repository_anchor_validator}" && ! -L "${repository_anchor_validator}" ]] || \ die 'repository trust-anchor validator is missing or is a symbolic link' +[[ -f "${environment_policy_reconciler}" && ! -L "${environment_policy_reconciler}" ]] || \ + die 'environment protection reconciler is missing or is a symbolic link' tmp_base=${TMPDIR:-/tmp} [[ -d "${tmp_base}" && ! -L "${tmp_base}" ]] || die 'temporary directory base is unsafe' @@ -372,7 +375,7 @@ repository_variable_expected() { } load_names_and_policy() { - local repository_json environment environment_json branch_policy_json kind output_file + local repository_json environment kind output_file protection_errors=0 find "${status_root}" -maxdepth 1 -type f -name 'github-environment-*.txt' -delete @@ -400,35 +403,13 @@ load_names_and_policy() { for environment in ${allowed_environments}; do environment_selected "${environment}" || continue - if ! environment_json=$(GH_PROMPT_DISABLED=1 gh api "repos/${repository}/environments/${environment}" 2>/dev/null); then - printf 'ENVIRONMENT name=%s protection=missing\n' "${environment}" + if ! PYTHONDONTWRITEBYTECODE=1 GH_PROMPT_DISABLED=1 \ + python3 "${environment_policy_reconciler}" audit --environment "${environment}" >/dev/null 2>&1; then + printf 'ENVIRONMENT name=%s protection=invalid-matrix\n' "${environment}" ((protection_errors += 1)) continue fi - if ! jq -e --arg name "${environment}" ' - .name == $name and - .deployment_branch_policy.protected_branches == false and - .deployment_branch_policy.custom_branch_policies == true - ' >/dev/null <<<"${environment_json}"; then - printf 'ENVIRONMENT name=%s protection=invalid\n' "${environment}" - ((protection_errors += 1)) - continue - fi - if ! branch_policy_json=$(GH_PROMPT_DISABLED=1 gh api \ - "repos/${repository}/environments/${environment}/deployment-branch-policies?per_page=100&page=1" 2>/dev/null); then - printf 'ENVIRONMENT name=%s protection=unreadable\n' "${environment}" - ((protection_errors += 1)) - continue - fi - if ! jq -e ' - .total_count == 1 and (.branch_policies | length) == 1 and - .branch_policies[0].name == "main" and .branch_policies[0].type == "branch" - ' >/dev/null <<<"${branch_policy_json}"; then - printf 'ENVIRONMENT name=%s protection=invalid-branch-policy\n' "${environment}" - ((protection_errors += 1)) - continue - fi - printf 'ENVIRONMENT name=%s protection=main-only\n' "${environment}" + printf 'ENVIRONMENT name=%s protection=exact-reviewed-matrix\n' "${environment}" for kind in secret variable; do output_file=${status_root}/github-environment-${environment}-${kind}.txt diff --git a/scripts/test-github-environment-main-policy.py b/scripts/test-github-environment-main-policy.py index c42eaac..e5b9689 100755 --- a/scripts/test-github-environment-main-policy.py +++ b/scripts/test-github-environment-main-policy.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Behavioral tests for exact-main GitHub environment reconciliation.""" +"""Behavioral tests for the exact PostgreSQL environment-protection matrix.""" from __future__ import annotations @@ -12,27 +12,101 @@ str(Path(__file__).with_name("reconcile-github-environment-main-policy.py")), run_name="postgres_environment_policy", ) +ATTESTATION_EXCEPTION = module["ATTESTATION_EXCEPTION"] +HUMAN_REVIEW_ENVIRONMENTS = module["HUMAN_REVIEW_ENVIRONMENTS"] PolicyError = module["PolicyError"] REQUIRED_ENVIRONMENTS = module["REQUIRED_ENVIRONMENTS"] +REVIEWER_ID = module["REVIEWER_ID"] +REVIEWER_LOGIN = module["REVIEWER_LOGIN"] +WAIT_TIMERS = module["WAIT_TIMERS"] audit_environment = module["audit_environment"] -build_preserving_update = module["build_preserving_update"] +build_expected_update = module["build_expected_update"] reconcile_environment = module["reconcile_environment"] +def pinned_reviewer(*, reviewer_id=REVIEWER_ID, login=REVIEWER_LOGIN, kind="User"): + return {"type": kind, "id": reviewer_id, "login": login} + + +def environment_payload( + environment, + *, + reviewer=True, + reviewer_id=REVIEWER_ID, + reviewer_login=REVIEWER_LOGIN, + reviewer_kind="User", + prevent_self_review=True, + wait_timer=0, + custom=True, + extra_rule=None, +): + rules = [] + if wait_timer: + rules.append({"type": "wait_timer", "wait_timer": wait_timer}) + if reviewer: + rules.append( + { + "type": "required_reviewers", + "prevent_self_review": prevent_self_review, + "reviewers": [ + { + "type": reviewer_kind, + "reviewer": { + "type": reviewer_kind, + "id": reviewer_id, + "login": reviewer_login, + }, + } + ], + } + ) + if extra_rule is not None: + rules.append(copy.deepcopy(extra_rule)) + rules.append({"type": "branch_policy"}) + return { + "name": environment, + "deployment_branch_policy": { + "protected_branches": not custom, + "custom_branch_policies": custom, + }, + "protection_rules": rules, + } + + class FakeClient: - def __init__(self, environment, policies): + def __init__(self, environment, policies, *, reviewers=None, discard_put=False): self.environment = copy.deepcopy(environment) self.policies = copy.deepcopy(policies) + self.reviewers = copy.deepcopy(reviewers or [pinned_reviewer()]) + self.discard_put = discard_put self.calls = [] self.next_id = 100 + def get_reviewer(self): + self.calls.append(("get-reviewer", REVIEWER_LOGIN)) + if len(self.reviewers) > 1: + return copy.deepcopy(self.reviewers.pop(0)) + return copy.deepcopy(self.reviewers[0]) + def get_environment(self, environment): self.calls.append(("get", environment)) return copy.deepcopy(self.environment) def put_environment(self, environment, payload): self.calls.append(("put", environment, copy.deepcopy(payload))) - self.environment["deployment_branch_policy"] = copy.deepcopy(payload["deployment_branch_policy"]) + if self.discard_put: + return + reviewers = payload["reviewers"] + self.environment = environment_payload( + environment, + reviewer=bool(reviewers), + reviewer_id=reviewers[0]["id"] if reviewers else REVIEWER_ID, + reviewer_login=REVIEWER_LOGIN, + reviewer_kind=reviewers[0]["type"] if reviewers else "User", + prevent_self_review=payload["prevent_self_review"], + wait_timer=payload["wait_timer"], + custom=True, + ) def list_policies(self, environment): self.calls.append(("list", environment)) @@ -48,37 +122,89 @@ def delete_policy(self, environment, policy_id): self.policies = [policy for policy in self.policies if policy["id"] != policy_id] -protected_environment = { - "deployment_branch_policy": {"protected_branches": True, "custom_branch_policies": False}, - "protection_rules": [ - {"type": "branch_policy"}, - {"type": "wait_timer", "wait_timer": 15}, - { - "type": "required_reviewers", - "prevent_self_review": True, - "reviewers": [{"type": "Team", "reviewer": {"id": 42}}], - }, - ], -} +def expect_policy_error(function, message): + try: + function() + except PolicyError: + return + raise AssertionError(message) + assert "production" in REQUIRED_ENVIRONMENTS -preserved = build_preserving_update(protected_environment) -assert preserved == { - "wait_timer": 15, +assert HUMAN_REVIEW_ENVIRONMENTS == set(REQUIRED_ENVIRONMENTS) - {"postgres-ci-attestation"} +assert WAIT_TIMERS == {environment: 0 for environment in REQUIRED_ENVIRONMENTS} +assert "must not wait for a human" in ATTESTATION_EXCEPTION + +expected_human_update = { + "wait_timer": 0, "prevent_self_review": True, - "reviewers": [{"type": "Team", "id": 42}], + "reviewers": [{"type": "User", "id": REVIEWER_ID}], + "deployment_branch_policy": {"protected_branches": False, "custom_branch_policies": True}, +} +for environment in HUMAN_REVIEW_ENVIRONMENTS: + assert build_expected_update(environment, pinned_reviewer()) == expected_human_update + client = FakeClient( + environment_payload(environment), + [{"id": 9, "name": "main", "type": "branch"}], + ) + audit_environment(client, environment) + +attestation = "postgres-ci-attestation" +expected_attestation_update = { + "wait_timer": 0, + "prevent_self_review": False, + "reviewers": [], "deployment_branch_policy": {"protected_branches": False, "custom_branch_policies": True}, } +assert build_expected_update(attestation, None) == expected_attestation_update +client = FakeClient( + environment_payload(attestation, reviewer=False, prevent_self_review=False), + [{"id": 9, "name": "main", "type": "branch"}], +) +audit_environment(client, attestation) +assert not any(call[0] == "get-reviewer" for call in client.calls) + +# Every human gate rejects missing, substituted, self-approvable, or delayed policy. +for candidate, label in ( + (environment_payload("production", reviewer=False), "missing reviewer"), + (environment_payload("production", reviewer_id=77), "substituted reviewer ID"), + (environment_payload("production", reviewer_login="renamed"), "substituted reviewer login"), + (environment_payload("production", prevent_self_review=False), "enabled self review"), + (environment_payload("production", wait_timer=15), "unexpected wait timer"), + ( + environment_payload("production", extra_rule={"type": "custom_protection_rule"}), + "unsupported protection rule", + ), +): + client = FakeClient(candidate, [{"id": 9, "name": "main", "type": "branch"}]) + expect_policy_error(lambda client=client: audit_environment(client, "production"), f"accepted {label}") -client = FakeClient(protected_environment, [{"id": 7, "name": "release/*", "type": "branch"}]) -try: - audit_environment(client, "production") -except PolicyError: - pass -else: - raise AssertionError("generic protected-branch policy was accepted") +client = FakeClient( + environment_payload(attestation), + [{"id": 9, "name": "main", "type": "branch"}], +) +expect_policy_error(lambda: audit_environment(client, attestation), "attestation accepted a human review rule") + +mismatched_name = environment_payload("production") +mismatched_name["name"] = "canary" +client = FakeClient(mismatched_name, [{"id": 9, "name": "main", "type": "branch"}]) +expect_policy_error(lambda: audit_environment(client, "production"), "accepted mismatched environment identity") + +# A pinned identity mismatch fails before any provider mutation. +client = FakeClient( + environment_payload("production", reviewer=False), + [{"id": 7, "name": "release/*", "type": "branch"}], + reviewers=[pinned_reviewer(reviewer_id=77)], +) +expect_policy_error(lambda: reconcile_environment(client, "production"), "accepted a stale reviewer snapshot") +assert not any(call[0] in {"put", "create", "delete"} for call in client.calls) -client.calls.clear() +# Reconciliation installs the exact matrix, creates main before deleting broad rules, +# and verifies the provider's post-write state. +client = FakeClient( + environment_payload("production", reviewer=False, custom=False), + [{"id": 7, "name": "release/*", "type": "branch"}], +) reconcile_environment(client, "production") audit_environment(client, "production") assert client.policies == [{"id": 100, "name": "main", "type": "branch"}] @@ -86,24 +212,23 @@ def delete_policy(self, environment, policy_id): index for index, call in enumerate(client.calls) if call[0] == "delete" ) put_call = next(call for call in client.calls if call[0] == "put") -assert put_call[2]["reviewers"] == [{"type": "Team", "id": 42}] -assert put_call[2]["wait_timer"] == 15 +assert put_call[2] == expected_human_update + +# The attestation exception is actively reconciled to no reviewers and no timer. +client = FakeClient( + environment_payload(attestation, wait_timer=15), + [{"id": 9, "name": "main", "type": "branch"}], +) +reconcile_environment(client, attestation) +assert next(call for call in client.calls if call[0] == "put")[2] == expected_attestation_update +assert not any(call[0] == "get-reviewer" for call in client.calls) +# A provider that accepts PUT but does not expose the exact state fails closed. client = FakeClient( - { - "deployment_branch_policy": {"protected_branches": False, "custom_branch_policies": True}, - "protection_rules": [{"type": "branch_policy"}], - }, - [ - {"id": 9, "name": "main", "type": "branch"}, - {"id": 10, "name": "main", "type": "tag"}, - ], + environment_payload("production", reviewer=False), + [{"id": 9, "name": "main", "type": "branch"}], + discard_put=True, ) -try: - audit_environment(client, "production") -except PolicyError: - pass -else: - raise AssertionError("additional tag policy was accepted") - -print("GitHub environment exact-main policy tests passed.") +expect_policy_error(lambda: reconcile_environment(client, "production"), "accepted a failed policy read-back") + +print("GitHub environment protection-matrix tests passed.") diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index 492fd69..2b72a58 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -99,6 +99,14 @@ if [[ "${1:-} ${2:-}" == 'auth status' ]]; then fi if [[ "${1:-}" == api ]]; then path=${*: -1} + if [[ "${path}" == users/idilsaglam ]]; then + if [[ "${FAKE_INVALID_REVIEWER_SNAPSHOT:-0}" == 1 ]]; then + printf '%s\n' '{"login":"idilsaglam","id":77,"type":"User"}' + else + printf '%s\n' '{"login":"idilsaglam","id":39597780,"type":"User"}' + fi + exit 0 + fi if [[ "${path}" == repos/Makepad-fr/postgres ]]; then if [[ "${FAKE_INVALID_REPOSITORY:-0}" == 1 ]]; then printf '%s\n' '{"full_name":"Makepad-fr/postgres","private":true,"visibility":"private","default_branch":"main","allow_forking":false,"archived":false,"disabled":false}' @@ -109,18 +117,44 @@ if [[ "${1:-}" == api ]]; then fi if [[ "${path}" == */deployment-branch-policies\?per_page=100\&page=1 ]]; then case "${FAKE_INVALID_PROTECTION:-0}" in - 1) printf '%s\n' '{"total_count":2,"branch_policies":[{"name":"main","type":"branch"},{"name":"release/*","type":"branch"}]}' ;; - tag) printf '%s\n' '{"total_count":1,"branch_policies":[{"name":"main","type":"tag"}]}' ;; - *) printf '%s\n' '{"total_count":1,"branch_policies":[{"name":"main","type":"branch"}]}' ;; + 1) printf '%s\n' '{"total_count":2,"branch_policies":[{"id":1,"name":"main","type":"branch"},{"id":2,"name":"release/*","type":"branch"}]}' ;; + tag) printf '%s\n' '{"total_count":1,"branch_policies":[{"id":1,"name":"main","type":"tag"}]}' ;; + *) printf '%s\n' '{"total_count":1,"branch_policies":[{"id":1,"name":"main","type":"branch"}]}' ;; esac exit 0 fi environment=${path##*/} + response_name=${environment} + [[ "${FAKE_INVALID_PROTECTION:-0}" != identity ]] || response_name=wrong-environment if [[ "${FAKE_INVALID_PROTECTION:-0}" == mode ]]; then - printf '{"name":"%s","deployment_branch_policy":{"protected_branches":true,"custom_branch_policies":false}}\n' "${environment}" + branch_mode='"protected_branches":true,"custom_branch_policies":false' else - printf '{"name":"%s","deployment_branch_policy":{"protected_branches":false,"custom_branch_policies":true}}\n' "${environment}" + branch_mode='"protected_branches":false,"custom_branch_policies":true' + fi + rules='[{"type":"branch_policy"}]' + if [[ "${environment}" != postgres-ci-attestation ]]; then + reviewer_id=39597780 + reviewer_login=idilsaglam + prevent_self_review=true + wait_rule= + case "${FAKE_INVALID_PROTECTION:-0}" in + reviewer) reviewer_id=77 ;; + reviewer-login) reviewer_login=renamed ;; + self-review) prevent_self_review=false ;; + wait) wait_rule='{"type":"wait_timer","wait_timer":15},' ;; + no-reviewer) reviewer_id= ;; + unknown) wait_rule='{"type":"custom_protection_rule"},' ;; + esac + if [[ -n "${reviewer_id}" ]]; then + rules="[${wait_rule}{\"type\":\"required_reviewers\",\"prevent_self_review\":${prevent_self_review},\"reviewers\":[{\"type\":\"User\",\"reviewer\":{\"type\":\"User\",\"id\":${reviewer_id},\"login\":\"${reviewer_login}\"}}]},{\"type\":\"branch_policy\"}]" + else + rules='[{"type":"branch_policy"}]' + fi + elif [[ "${FAKE_INVALID_PROTECTION:-0}" == attestation-reviewer ]]; then + rules='[{"type":"required_reviewers","prevent_self_review":true,"reviewers":[{"type":"User","reviewer":{"type":"User","id":39597780,"login":"idilsaglam"}}]},{"type":"branch_policy"}]' fi + printf '{"name":"%s","deployment_branch_policy":{%s},"protection_rules":%s}\n' \ + "${response_name}" "${branch_mode}" "${rules}" exit 0 fi @@ -323,14 +357,27 @@ run_helper 1 env FAKE_INVALID_REPOSITORY=1 "${helper}" --sync --environment cana grep -Fq 'REPOSITORY name=Makepad-fr/postgres policy=invalid' <<<"${output}" assert_no_value_read_or_write -for invalid_policy in 1 tag mode; do +for invalid_policy in 1 tag mode reviewer reviewer-login self-review wait no-reviewer unknown identity; do : >"${audit_log}" run_helper 1 env FAKE_INVALID_PROTECTION="${invalid_policy}" \ "${helper}" --sync --environment release-brio-identity-db - grep -Eq 'protection=(invalid|invalid-branch-policy)' <<<"${output}" + grep -Fq 'protection=invalid-matrix' <<<"${output}" assert_no_value_read_or_write done +: >"${audit_log}" +run_helper 1 env FAKE_INVALID_PROTECTION=attestation-reviewer \ + "${helper}" --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables +grep -Fq 'protection=invalid-matrix' <<<"${output}" +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 1 env FAKE_INVALID_REVIEWER_SNAPSHOT=1 \ + "${helper}" --sync --environment production +grep -Fq 'protection=invalid-matrix' <<<"${output}" +assert_no_value_read_or_write + : >"${audit_log}" run_helper 1 env FAKE_MISSING_REPOSITORY_VARIABLE=POSTGRES_CI_ATTESTATION_PUBLIC_KEY \ "${helper}" --sync --environment postgres-ci-attestation @@ -433,6 +480,8 @@ install -d -m 0700 "${candidate_root}/scripts" "${candidate_root}/deploy" cp "${helper}" "${candidate_root}/scripts/sync-github-environments.sh" cp "${repo_root}/scripts/validate-repository-trust-anchor.py" \ "${candidate_root}/scripts/validate-repository-trust-anchor.py" +cp "${repo_root}/scripts/reconcile-github-environment-main-policy.py" \ + "${candidate_root}/scripts/reconcile-github-environment-main-policy.py" chmod 0755 "${candidate_root}/scripts/sync-github-environments.sh" jq '(.githubEntries[] | select(.destination == "BRIO_IDENTITY_DB_HOSTNAME")).kind = "secret"' \ diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 5cbf23d..3fa66bf 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -146,11 +146,29 @@ for required in ( '"custom_branch_policies": True', '{"name": "main", "type": "branch"}', "MAX_POLICY_PAGES = 1000", - "build_preserving_update", + 'REVIEWER_LOGIN = "idilsaglam"', + "REVIEWER_ID = 39597780", + "HUMAN_REVIEW_ENVIRONMENTS", + "WAIT_TIMERS", + "ATTESTATION_EXCEPTION", + "reviewer_snapshot", + "protection_snapshot", + "build_expected_update", + "audit_protection", "audit_environment(client, environment)", + "protected-policy-v1", ): require(required in environment_policy_reconciler, f"Environment policy reconciler is missing: {required}") require('assert "production" in REQUIRED_ENVIRONMENTS' in environment_policy_test, "Environment policy test must cover production explicitly.") +for required in ( + "HUMAN_REVIEW_ENVIRONMENTS == set(REQUIRED_ENVIRONMENTS)", + "expected_attestation_update", + "enabled self review", + "unexpected wait timer", + "stale reviewer snapshot", + "failed policy read-back", +): + require(required in environment_policy_test, f"Environment policy test is missing adversarial coverage: {required}") require("python3 scripts/test-github-environment-main-policy.py" in ci_runner, "CI must run the environment policy behavioral test.") require( "exactly one custom branch deployment policy whose type is `branch` and whose name is exactly `main`" in normalized_readme, @@ -813,8 +831,8 @@ for required in ( 'gh variable get "${destination}" --repo "${repository}" --json value', "REPOSITORY name=%s policy=public-active-main", 'status=forbidden', - 'branch_policies[0].name == "main"', - 'branch_policies[0].type == "branch"', + "environment_policy_reconciler", + "protection=exact-reviewed-matrix", ): require(required in credential_sync, f"Credential sync helper is missing fail-closed control: {required}") for forbidden in ("gh secret delete", "gh variable delete", "pass-cli item delete"): From 8deb57ca7e5cdf937803d7ac8f84e275c060c807 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 07:21:00 +0200 Subject: [PATCH 10/16] test(credentials): pin complete inventory contract --- docs/credential-sync.md | 6 + scripts/run-ci.sh | 1 + scripts/sync-github-environments.sh | 5 + scripts/test-sync-github-environments.sh | 57 +++- .../validate-credential-inventory-contract.py | 263 ++++++++++++++++++ scripts/validate-postgres-config.sh | 19 +- scripts/validate-repository-trust-anchor.py | 0 7 files changed, 347 insertions(+), 4 deletions(-) create mode 100755 scripts/validate-credential-inventory-contract.py mode change 100644 => 100755 scripts/validate-repository-trust-anchor.py diff --git a/docs/credential-sync.md b/docs/credential-sync.md index 701faa2..b9b3beb 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -6,6 +6,12 @@ protected GitHub environments, the four public repository policy variables, and the root/operator boundaries used by the disposable-runner control plane. Repository code never creates, rotates, or deletes a credential. +`scripts/validate-credential-inventory-contract.py` independently pins every +environment/kind/destination/Proton-item/field tuple, plus every repository and +non-GitHub tuple. Both check and sync modes run it before the first provider +call, so a plausible-looking source substitution or reclassification fails +closed rather than becoming a new implicit credential route. + Run the non-mutating audit first: ```sh diff --git a/scripts/run-ci.sh b/scripts/run-ci.sh index 12bc9c5..3c0ca92 100755 --- a/scripts/run-ci.sh +++ b/scripts/run-ci.sh @@ -50,6 +50,7 @@ for source in ( "scripts/verify-postgres-ci-jit-result.py", "scripts/reconcile-github-environment-main-policy.py", "scripts/test-github-environment-main-policy.py", + "scripts/validate-credential-inventory-contract.py", "scripts/validate-repository-trust-anchor.py", ): ast.parse(Path(source).read_text(), filename=source) diff --git a/scripts/sync-github-environments.sh b/scripts/sync-github-environments.sh index d0ca773..d0f4721 100755 --- a/scripts/sync-github-environments.sh +++ b/scripts/sync-github-environments.sh @@ -16,6 +16,7 @@ readonly allowed_environments='canary production staging-brio-identity-db releas repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) readonly repo_root readonly inventory=${repo_root}/deploy/credential-inventory.json +readonly inventory_contract_validator=${repo_root}/scripts/validate-credential-inventory-contract.py readonly repository_anchor_validator=${repo_root}/scripts/validate-repository-trust-anchor.py readonly environment_policy_reconciler=${repo_root}/scripts/reconcile-github-environment-main-policy.py readonly max_value_bytes=49152 @@ -90,10 +91,14 @@ for command_name in pass-cli gh jq python3 sort grep awk mktemp find wc tr; do command -v "${command_name}" >/dev/null || die "${command_name} is required" done [[ -f "${inventory}" && ! -L "${inventory}" ]] || die 'credential inventory is missing or is a symbolic link' +[[ -f "${inventory_contract_validator}" && ! -L "${inventory_contract_validator}" ]] || \ + die 'credential inventory contract validator is missing or is a symbolic link' [[ -f "${repository_anchor_validator}" && ! -L "${repository_anchor_validator}" ]] || \ die 'repository trust-anchor validator is missing or is a symbolic link' [[ -f "${environment_policy_reconciler}" && ! -L "${environment_policy_reconciler}" ]] || \ die 'environment protection reconciler is missing or is a symbolic link' +PYTHONDONTWRITEBYTECODE=1 python3 "${inventory_contract_validator}" "${inventory}" || \ + die 'credential inventory does not match the immutable reviewed contract' tmp_base=${TMPDIR:-/tmp} [[ -d "${tmp_base}" && ! -L "${tmp_base}" ]] || die 'temporary directory base is unsafe' diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index 2b72a58..1f2c9b1 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -482,20 +482,73 @@ cp "${repo_root}/scripts/validate-repository-trust-anchor.py" \ "${candidate_root}/scripts/validate-repository-trust-anchor.py" cp "${repo_root}/scripts/reconcile-github-environment-main-policy.py" \ "${candidate_root}/scripts/reconcile-github-environment-main-policy.py" +cp "${repo_root}/scripts/validate-credential-inventory-contract.py" \ + "${candidate_root}/scripts/validate-credential-inventory-contract.py" chmod 0755 "${candidate_root}/scripts/sync-github-environments.sh" jq '(.githubEntries[] | select(.destination == "BRIO_IDENTITY_DB_HOSTNAME")).kind = "secret"' \ "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" : >"${audit_log}" run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check --environment staging-brio-identity-db -grep -Fq 'wrong public/secret classification' <<<"${output}" +grep -Fq 'per-environment kind/destination/item/field matrix differs from review' <<<"${output}" [[ ! -s "${audit_log}" ]] jq '.githubEntries += [{"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"POSTGRES_SERVER_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"POSTGRES_SERVER_CERT_PEM"}]' \ "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" : >"${audit_log}" run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check --environment staging-brio-identity-db -grep -Fq 'PKI destinations do not match' <<<"${output}" +grep -Fq 'per-environment kind/destination/item/field matrix differs from review' <<<"${output}" +[[ ! -s "${audit_log}" ]] + +# Every environment is pinned as a complete kind/destination/item/field set. +while IFS= read -r FAKE_ADVERSARIAL_ENVIRONMENT; do + first_destination=$(jq -r --arg environment "${FAKE_ADVERSARIAL_ENVIRONMENT}" \ + '[.githubEntries[] | select(.environment == $environment)][0].destination' "${inventory}") + jq --arg environment "${FAKE_ADVERSARIAL_ENVIRONMENT}" --arg destination "${first_destination}" ' + (.githubEntries[] | select(.environment == $environment and .destination == $destination)).field = "adversarial_field" + ' "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" + : >"${audit_log}" + run_helper 1 env FAKE_ADVERSARIAL_ENVIRONMENT="${FAKE_ADVERSARIAL_ENVIRONMENT}" \ + "${candidate_root}/scripts/sync-github-environments.sh" --check \ + --environment "${FAKE_ADVERSARIAL_ENVIRONMENT}" + grep -Fq 'per-environment kind/destination/item/field matrix differs from review' <<<"${output}" + [[ ! -s "${audit_log}" ]] +done < <(jq -r '.githubEntries[].environment' "${inventory}" | sort -u) + +for mutation in kind destination item requirement; do + case "${mutation}" in + kind) + filter='(.githubEntries[0].kind) = "variable"' + ;; + destination) + filter='(.githubEntries[0].destination) = "ADVERSARIAL_DESTINATION"' + ;; + item) + filter='(.githubEntries[0].item) = "Adversarial Proton item"' + ;; + requirement) + filter='(.githubEntries[0].requirement) = "optional"' + ;; + esac + jq "${filter}" "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" + : >"${audit_log}" + run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check --environment canary + grep -Eq 'per-environment kind/destination/item/field matrix differs from review|is not required' <<<"${output}" + [[ ! -s "${audit_log}" ]] +done + +jq '(.repositoryVariables[0].item) = "Adversarial Proton item"' \ + "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" +: >"${audit_log}" +run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check --environment postgres-ci-attestation +grep -Fq 'repository-variable destination/item/field matrix differs from review' <<<"${output}" +[[ ! -s "${audit_log}" ]] + +jq '(.nonGitHubEntries[0].field) = "adversarial_field"' \ + "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" +: >"${audit_log}" +run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check +grep -Fq 'non-GitHub boundary/destination/item/field matrix differs from review' <<<"${output}" [[ ! -s "${audit_log}" ]] printf '%s\n' 'PostgreSQL Proton-to-GitHub credential sync tests passed.' diff --git a/scripts/validate-credential-inventory-contract.py b/scripts/validate-credential-inventory-contract.py new file mode 100755 index 0000000..9351cf2 --- /dev/null +++ b/scripts/validate-credential-inventory-contract.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Validate the complete reviewed PostgreSQL Proton-to-GitHub mapping.""" + +from __future__ import annotations + +import json +import pathlib +import sys +from typing import Any, NoReturn + + +REPOSITORY = "Makepad-fr/postgres" +VAULT = "Makepad" +REQUIRED = "required" + + +def github(item: str, **destinations: str) -> set[tuple[str, str, str, str]]: + return { + ("secret", destination, item, field) + for destination, field in destinations.items() + } + + +EXPECTED_GITHUB_ENTRIES = { + "canary": ( + github( + "Hetzner App Server makepad", + DEPLOY_SSH_HOST="host", + DEPLOY_SSH_PORT="port", + DEPLOY_SSH_USER="user", + DEPLOY_SSH_PRIVATE_KEY="private_key", + DEPLOY_SSH_KNOWN_HOSTS="known_hosts", + ) + | github( + "PostgreSQL · shared Swarm deployment", + DEPLOY_REMOTE_DIR="DEPLOY_REMOTE_DIR", + DEPLOY_STACK_NAME="DEPLOY_STACK_NAME", + DEPLOY_CATWLK_DB_NETWORK="DEPLOY_CATWLK_DB_NETWORK", + DEPLOY_LE_PETIT_COIN_DB_NETWORK="DEPLOY_LE_PETIT_COIN_DB_NETWORK", + DEPLOY_BRIO_STAGING_DB_NETWORK="DEPLOY_BRIO_STAGING_DB_NETWORK", + ) + | github( + "Brio Staging - PostgreSQL", + POSTGRES_CANARY_SUPERUSER_PASSWORD="POSTGRES_CANARY_SUPERUSER_PASSWORD", + BRIO_STAGING_DB_PASSWORD="BRIO_STAGING_DB_PASSWORD", + BRIO_STAGING_BACKUP_DB_PASSWORD="BRIO_STAGING_BACKUP_DB_PASSWORD", + ) + | github( + "Brio Staging - PKI and Backup Keys", + POSTGRES_CA_PEM="POSTGRES_CA_PEM", + POSTGRES_SERVER_CERT_PEM="POSTGRES_SERVER_CERT_PEM", + POSTGRES_SERVER_KEY_PEM="POSTGRES_SERVER_KEY_PEM", + BRIO_BACKUP_RECIPIENT_CERT_PEM="BRIO_BACKUP_RECIPIENT_CERT_PEM", + ) + ), + "production": ( + github( + "Hetzner App Server makepad", + DEPLOY_SSH_HOST="host", + DEPLOY_SSH_PORT="port", + DEPLOY_SSH_USER="user", + DEPLOY_SSH_PRIVATE_KEY="private_key", + DEPLOY_SSH_KNOWN_HOSTS="known_hosts", + ) + | github( + "PostgreSQL · shared Swarm deployment", + DEPLOY_REMOTE_DIR="DEPLOY_REMOTE_DIR", + DEPLOY_STACK_NAME="DEPLOY_STACK_NAME", + DEPLOY_CATWLK_DB_NETWORK="DEPLOY_CATWLK_DB_NETWORK", + DEPLOY_LE_PETIT_COIN_DB_NETWORK="DEPLOY_LE_PETIT_COIN_DB_NETWORK", + DEPLOY_VIF_DB_NETWORK="DEPLOY_VIF_DB_NETWORK", + DEPLOY_VIF_DB_NAME="DEPLOY_VIF_DB_NAME", + DEPLOY_VIF_DB_USER="DEPLOY_VIF_DB_USER", + DEPLOY_VIF_DB_PASSWORD="DEPLOY_VIF_DB_PASSWORD", + ) + ), + "staging-brio-identity-db": ( + github( + "Hetzner Database Server makepad", + BRIO_IDENTITY_DB_DEPLOY_SSH_HOST="host", + BRIO_IDENTITY_DB_DEPLOY_SSH_PORT="port", + BRIO_IDENTITY_DB_DEPLOY_SSH_USER="user", + BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY="private_key", + BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS="known_hosts", + ) + | github( + "Brio Staging - PostgreSQL", + KEYCLOAK_BRIO_STAGING_DB_PASSWORD="KEYCLOAK_BRIO_STAGING_DB_PASSWORD", + KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD="KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD", + ) + | github( + "Brio Staging - PKI and Backup Keys", + BRIO_BACKUP_RECIPIENT_CERT_PEM="BRIO_BACKUP_RECIPIENT_CERT_PEM", + ) + | { + ( + "variable", + "BRIO_IDENTITY_DB_HOSTNAME", + "Brio Staging - PKI and Backup Keys", + "BRIO_IDENTITY_DB_HOSTNAME", + ), + ( + "variable", + "BRIO_KEYCLOAK_DB_SOURCE_CIDR", + "Brio Staging - PKI and Backup Keys", + "BRIO_KEYCLOAK_DB_SOURCE_CIDR", + ), + } + ), + "release-brio-identity-db": github( + "PostgreSQL · Brio identity release orchestrator", + KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN="KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN", + ), + "keycloak-cohort-restore": ( + github( + "PostgreSQL · Keycloak cohort source reader", + KEYCLOAK_COHORT_SOURCE_TOKEN="KEYCLOAK_COHORT_SOURCE_TOKEN", + ) + | github( + "Hetzner Database Server makepad", + KEYCLOAK_COHORT_DB_SSH_HOST="host", + KEYCLOAK_COHORT_DB_SSH_PORT="port", + KEYCLOAK_COHORT_DB_SSH_USER="user", + KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY="private_key", + KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS="known_hosts", + ) + | github( + "Makepad Docker Hardened Images", + DHI_REGISTRY_USERNAME="DOCKERHUB_USERNAME", + DHI_REGISTRY_PASSWORD="DOCKERHUB_PRO_PAT", + ) + ), + "postgres-ci-attestation": github( + "PostgreSQL · PR Checks App", + POSTGRES_PR_CHECK_APP_PRIVATE_KEY="private_key", + ), +} + +EXPECTED_REPOSITORY_VARIABLES = { + ( + "POSTGRES_CI_LAUNCHER_APP_SENDER_ID", + "PostgreSQL · JIT Launcher App", + "bot_user_id", + ), + ( + "POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256", + "PostgreSQL · JIT hypervisor attestation", + "qcow2_sha256", + ), + ( + "POSTGRES_CI_ATTESTATION_PUBLIC_KEY", + "PostgreSQL · JIT hypervisor attestation", + "ed25519_public_key", + ), + ( + "POSTGRES_PR_CHECK_APP_ID", + "PostgreSQL · PR Checks App", + "app_id", + ), +} + +EXPECTED_NON_GITHUB_ENTRIES = { + ("operator-verification", "PostgreSQL Checks App private-key fingerprint", "PostgreSQL · PR Checks App", "private_key_fingerprint"), + ("host-root-setting", "/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_LAUNCHER_APP_ID", "PostgreSQL · JIT Launcher App", "app_id"), + ("host-root-setting", "/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_LAUNCHER_APP_INSTALLATION_ID", "PostgreSQL · JIT Launcher App", "installation_id"), + ("host-root-file", "/etc/makepad/postgres-ci/launcher-app-private-key.pem", "PostgreSQL · JIT Launcher App", "private_key"), + ("operator-verification", "PostgreSQL Launcher App private-key fingerprint", "PostgreSQL · JIT Launcher App", "private_key_fingerprint"), + ("host-root-setting", "/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_REPOSITORY_ID", "PostgreSQL · JIT Launcher App", "repository_id"), + ("host-root-file", "/etc/makepad/postgres-ci/attestation-private-key.pem", "PostgreSQL · JIT hypervisor attestation", "ed25519_private_key"), + ("operator-verification", "PostgreSQL Ed25519 public-key fingerprint", "PostgreSQL · JIT hypervisor attestation", "public_key_fingerprint"), + ("host-root-setting", "/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_BASE_IMAGE_SHA256", "PostgreSQL · JIT hypervisor attestation", "qcow2_sha256"), + ("operator-stdin", "scripts/configure-postgres-ci-runner-group.sh standard input", "PostgreSQL · runner-group controller", "organization_runner_admin_token"), + ("host-root-file", "POSTGRES_HOST_ALERT_URL_FILE", "PostgreSQL · CI hypervisor alert", "url"), +} + + +def fail(message: str) -> NoReturn: + raise SystemExit(f"credential inventory contract violation: {message}") + + +def exact_entries(entries: Any, keys: set[str], label: str) -> list[dict[str, str]]: + if not isinstance(entries, list) or not entries: + fail(f"{label} must be a non-empty list") + for offset, entry in enumerate(entries): + if not isinstance(entry, dict) or set(entry) != keys: + fail(f"{label} entry {offset} has unexpected keys") + if not all(isinstance(value, str) and value for value in entry.values()): + fail(f"{label} entry {offset} contains invalid text") + if entry.get("requirement") != REQUIRED: + fail(f"{label} entry {offset} is not required") + return entries + + +def validate_inventory(payload: Any) -> None: + if not isinstance(payload, dict) or set(payload) != { + "schemaVersion", + "repository", + "vault", + "githubEntries", + "repositoryVariables", + "nonGitHubEntries", + }: + fail("top-level shape differs from the reviewed contract") + if payload["schemaVersion"] != 1 or payload["repository"] != REPOSITORY or payload["vault"] != VAULT: + fail("schema, repository, or vault identity differs from the reviewed contract") + + github_entries = exact_entries( + payload["githubEntries"], + {"environment", "kind", "requirement", "destination", "item", "field"}, + "GitHub environment", + ) + actual_by_environment: dict[str, set[tuple[str, str, str, str]]] = {} + for entry in github_entries: + environment = entry["environment"] + identity = (entry["kind"], entry["destination"], entry["item"], entry["field"]) + bucket = actual_by_environment.setdefault(environment, set()) + if identity in bucket: + fail(f"duplicate GitHub mapping in {environment}") + bucket.add(identity) + if actual_by_environment != EXPECTED_GITHUB_ENTRIES: + fail("per-environment kind/destination/item/field matrix differs from review") + + repository_entries = exact_entries( + payload["repositoryVariables"], + {"requirement", "destination", "item", "field"}, + "repository variable", + ) + actual_repository = { + (entry["destination"], entry["item"], entry["field"]) + for entry in repository_entries + } + if len(actual_repository) != len(repository_entries) or actual_repository != EXPECTED_REPOSITORY_VARIABLES: + fail("repository-variable destination/item/field matrix differs from review") + + non_github_entries = exact_entries( + payload["nonGitHubEntries"], + {"boundary", "requirement", "destination", "item", "field"}, + "non-GitHub boundary", + ) + actual_non_github = { + (entry["boundary"], entry["destination"], entry["item"], entry["field"]) + for entry in non_github_entries + } + if len(actual_non_github) != len(non_github_entries) or actual_non_github != EXPECTED_NON_GITHUB_ENTRIES: + fail("non-GitHub boundary/destination/item/field matrix differs from review") + + +def main() -> int: + if len(sys.argv) != 2: + fail("expected exactly one inventory path") + path = pathlib.Path(sys.argv[1]) + if not path.is_file() or path.is_symlink(): + fail("inventory path is missing, non-regular, or symbolic") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + fail(f"inventory cannot be parsed: {type(error).__name__}") + validate_inventory(payload) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 3fa66bf..8562cf6 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -9,6 +9,10 @@ fi script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) repo_root=$(cd "${script_dir}/.." && pwd) +PYTHONDONTWRITEBYTECODE=1 python3 \ + "${repo_root}/scripts/validate-credential-inventory-contract.py" \ + "${repo_root}/deploy/credential-inventory.json" + REPO_ROOT="${repo_root}" python3 - <<'PY' import json import os @@ -107,6 +111,7 @@ environment_policy_reconciler = read_required_text(repo_root / "scripts/reconcil environment_policy_test = read_required_text(repo_root / "scripts/test-github-environment-main-policy.py", "GitHub environment policy test") credential_sync = read_required_text(repo_root / "scripts/sync-github-environments.sh", "credential sync helper") credential_sync_test = read_required_text(repo_root / "scripts/test-sync-github-environments.sh", "credential sync behavioral test") +inventory_contract_validator = read_required_text(repo_root / "scripts/validate-credential-inventory-contract.py", "credential inventory contract validator") repository_anchor_validator = read_required_text(repo_root / "scripts/validate-repository-trust-anchor.py", "repository trust-anchor validator") release_evidence_validator = read_required_text(repo_root / "scripts/verify-brio-release-evidence.py", "Brio release evidence validator") cohort_evidence_validator = read_required_text(repo_root / "scripts/verify-keycloak-cohort-evidence.py", "Keycloak cohort evidence validator") @@ -833,6 +838,7 @@ for required in ( 'status=forbidden', "environment_policy_reconciler", "protection=exact-reviewed-matrix", + "inventory_contract_validator", ): require(required in credential_sync, f"Credential sync helper is missing fail-closed control: {required}") for forbidden in ("gh secret delete", "gh variable delete", "pass-cli item delete"): @@ -855,13 +861,22 @@ for required in ( "FAKE_INVALID_PROTECTION", "FAKE_MISSING_FIELD", "FAKE_OVERSIZED_FIELD", - "wrong public/secret classification", - "PKI destinations do not match", "FAKE_INVALID_ANCHOR_FIELD", "FAKE_READBACK_MISMATCH", "repository_last_source_read < repository_first_write", + "per-environment kind/destination/item/field matrix differs from review", + "FAKE_ADVERSARIAL_ENVIRONMENT", ): require(required in credential_sync_test, f"Credential sync behavioral test is missing adversarial case: {required}") +for required in ( + "EXPECTED_GITHUB_ENTRIES", + "EXPECTED_REPOSITORY_VARIABLES", + "EXPECTED_NON_GITHUB_ENTRIES", + "per-environment kind/destination/item/field matrix differs from review", + "repository-variable destination/item/field matrix differs from review", + "non-GitHub boundary/destination/item/field matrix differs from review", +): + require(required in inventory_contract_validator, f"Credential inventory contract validator is missing: {required}") require("./scripts/test-sync-github-environments.sh" in ci_runner, "CI must run the credential sync behavioral test.") require("pass-cli item view --item-title '' --field ''" in readme, "README must document stdin-only pass-cli credential synchronization.") require("| gh secret set '' --env '' --repo 'Makepad-fr/postgres'" in normalized_readme, "README must mirror workflow secrets only into protected GitHub environments.") diff --git a/scripts/validate-repository-trust-anchor.py b/scripts/validate-repository-trust-anchor.py old mode 100644 new mode 100755 From 6dd8fde103d7c5ca8b3818468223807cb2f8169f Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 08:13:43 +0200 Subject: [PATCH 11/16] fix(credentials): use canonical Proton source fields --- README.md | 7 ++-- deploy/credential-inventory.json | 26 +++++++-------- docs/credential-sync.md | 22 ++++++++----- scripts/test-sync-github-environments.sh | 27 +++++++++------ .../validate-credential-inventory-contract.py | 33 ++++++++++++------- scripts/validate-postgres-config.sh | 15 ++++++--- 6 files changed, 79 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 0f87145..c8ad489 100644 --- a/README.md +++ b/README.md @@ -235,9 +235,10 @@ GitHub environment variables. The exact Brio inventory is: | Canonical Proton Pass item | Protected GitHub environment | Exact mirrored fields | | --- | --- | --- | | `Hetzner App Server makepad` | `canary` and `production` | native fields `host`, `port`, `user`, `private_key`, and `known_hosts` map to the five `DEPLOY_SSH_*` destinations for the application Swarm manager required by `manual-deploy.yml` | -| `Hetzner Database Server makepad` | `staging-brio-identity-db` and `keycloak-cohort-restore` | the same five native SSH fields map to the workflow aliases `BRIO_IDENTITY_DB_DEPLOY_SSH_*` and `KEYCLOAK_COHORT_DB_SSH_*` only in their named standalone DB-host environments | -| `PostgreSQL · shared Swarm deployment` | `canary` and `production` | current workflow-compatible protected fields for remote directory, stack, shared networks, and the production-only VIF database name, role, network, and password; exact destinations are in `deploy/credential-inventory.json` | -| `Brio Staging - PostgreSQL` | `canary`; Keycloak passwords only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, and `BRIO_STAGING_BACKUP_DB_PASSWORD`; only `KEYCLOAK_BRIO_STAGING_DB_PASSWORD` and `KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD` are mirrored to `staging-brio-identity-db` | +| `Hetzner Database Server makepad` | `staging-brio-identity-db` and `keycloak-cohort-restore` | canonical custom fields `DEPLOY_SSH_HOST`, `DEPLOY_SSH_PORT`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_PRIVATE_KEY`, and `DEPLOY_SSH_KNOWN_HOSTS` map to the workflow aliases `BRIO_IDENTITY_DB_DEPLOY_SSH_*` and `KEYCLOAK_COHORT_DB_SSH_*` only in their named standalone DB-host environments | +| `PostgreSQL · shared Swarm deployment` | `canary` and `production` | current workflow-compatible protected fields for the PostgreSQL remote directory, stack, Catwlk network, and production-only VIF database name, role, network, and password; exact destinations are in `deploy/credential-inventory.json` | +| `Le Petit Coin GitHub Deploy Secrets` | `canary` and `production` | canonical `DEPLOY_DB_NETWORK` maps to the PostgreSQL workflow alias `DEPLOY_LE_PETIT_COIN_DB_NETWORK`, keeping both stacks on the same application-owned database overlay | +| `Brio Staging - PostgreSQL` | `canary`; Keycloak passwords only in `staging-brio-identity-db` | `canary` secrets `DEPLOY_BRIO_STAGING_DB_NETWORK`, `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, and `BRIO_STAGING_BACKUP_DB_PASSWORD`; only `KEYCLOAK_BRIO_STAGING_DB_PASSWORD` and `KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD` are mirrored to `staging-brio-identity-db` | | `Brio Staging - PKI and Backup Keys` | `canary`; recipient certificate only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CA_PEM`, `POSTGRES_SERVER_CERT_PEM`, `POSTGRES_SERVER_KEY_PEM`, and `BRIO_BACKUP_RECIPIENT_CERT_PEM`; only `BRIO_BACKUP_RECIPIENT_CERT_PEM` is mirrored to `staging-brio-identity-db` | | `PostgreSQL · Brio identity release orchestrator` | `release-brio-identity-db` | secret `KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN` | | `PostgreSQL · Keycloak cohort source reader` | `keycloak-cohort-restore` | secret `KEYCLOAK_COHORT_SOURCE_TOKEN` | diff --git a/deploy/credential-inventory.json b/deploy/credential-inventory.json index f3fbdb1..b726f00 100644 --- a/deploy/credential-inventory.json +++ b/deploy/credential-inventory.json @@ -11,8 +11,8 @@ {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_REMOTE_DIR","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_REMOTE_DIR"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_STACK_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_STACK_NAME"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_CATWLK_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_CATWLK_DB_NETWORK"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_LE_PETIT_COIN_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_LE_PETIT_COIN_DB_NETWORK"}, - {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_BRIO_STAGING_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_BRIO_STAGING_DB_NETWORK"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_LE_PETIT_COIN_DB_NETWORK","item":"Le Petit Coin GitHub Deploy Secrets","field":"DEPLOY_DB_NETWORK"}, + {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_BRIO_STAGING_DB_NETWORK","item":"Brio Staging - PostgreSQL","field":"DEPLOY_BRIO_STAGING_DB_NETWORK"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"POSTGRES_CANARY_SUPERUSER_PASSWORD","item":"Brio Staging - PostgreSQL","field":"POSTGRES_CANARY_SUPERUSER_PASSWORD"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"BRIO_STAGING_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"BRIO_STAGING_DB_PASSWORD"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"BRIO_STAGING_BACKUP_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"BRIO_STAGING_BACKUP_DB_PASSWORD"}, @@ -29,17 +29,17 @@ {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_REMOTE_DIR","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_REMOTE_DIR"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_STACK_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_STACK_NAME"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_CATWLK_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_CATWLK_DB_NETWORK"}, - {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_LE_PETIT_COIN_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_LE_PETIT_COIN_DB_NETWORK"}, + {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_LE_PETIT_COIN_DB_NETWORK","item":"Le Petit Coin GitHub Deploy Secrets","field":"DEPLOY_DB_NETWORK"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_NETWORK","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_NETWORK"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_NAME","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_NAME"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_USER","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_USER"}, {"environment":"production","kind":"secret","requirement":"required","destination":"DEPLOY_VIF_DB_PASSWORD","item":"PostgreSQL · shared Swarm deployment","field":"DEPLOY_VIF_DB_PASSWORD"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"host"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"port"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"user"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"private_key"}, - {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"known_hosts"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_BRIO_STAGING_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"KEYCLOAK_BRIO_STAGING_DB_PASSWORD"}, {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD","item":"Brio Staging - PostgreSQL","field":"KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD"}, {"environment":"staging-brio-identity-db","kind":"secret","requirement":"required","destination":"BRIO_BACKUP_RECIPIENT_CERT_PEM","item":"Brio Staging - PKI and Backup Keys","field":"BRIO_BACKUP_RECIPIENT_CERT_PEM"}, @@ -49,11 +49,11 @@ {"environment":"release-brio-identity-db","kind":"secret","requirement":"required","destination":"KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN","item":"PostgreSQL · Brio identity release orchestrator","field":"KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN"}, {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_SOURCE_TOKEN","item":"PostgreSQL · Keycloak cohort source reader","field":"KEYCLOAK_COHORT_SOURCE_TOKEN"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_HOST","item":"Hetzner Database Server makepad","field":"host"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PORT","item":"Hetzner Database Server makepad","field":"port"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_USER","item":"Hetzner Database Server makepad","field":"user"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"private_key"}, - {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"known_hosts"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_HOST","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_HOST"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PORT","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PORT"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_USER","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_USER"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS","item":"Hetzner Database Server makepad","field":"DEPLOY_SSH_KNOWN_HOSTS"}, {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"DHI_REGISTRY_USERNAME","item":"Makepad Docker Hardened Images","field":"DOCKERHUB_USERNAME"}, {"environment":"keycloak-cohort-restore","kind":"secret","requirement":"required","destination":"DHI_REGISTRY_PASSWORD","item":"Makepad Docker Hardened Images","field":"DOCKERHUB_PRO_PAT"}, diff --git a/docs/credential-sync.md b/docs/credential-sync.md index b9b3beb..73ed4c0 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -82,9 +82,12 @@ and CIDRs consumed as `vars.*` are environment variables. - `Hetzner App Server makepad` fields `host`, `port`, `user`, `private_key`, and `known_hosts` map to the five exact `DEPLOY_SSH_*` secrets. -- `PostgreSQL · shared Swarm deployment` maps the remote directory, stack, and - three network fields used by the canary workflow. -- `Brio Staging - PostgreSQL` maps only +- `PostgreSQL · shared Swarm deployment` maps the PostgreSQL remote directory, + stack, and Catwlk network used by the canary workflow. +- `Le Petit Coin GitHub Deploy Secrets/DEPLOY_DB_NETWORK` maps to + `DEPLOY_LE_PETIT_COIN_DB_NETWORK`, so PostgreSQL joins the exact application- + owned database overlay rather than relying on a duplicated network name. +- `Brio Staging - PostgreSQL` maps `DEPLOY_BRIO_STAGING_DB_NETWORK`, `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, and `BRIO_STAGING_BACKUP_DB_PASSWORD`. - `Brio Staging - PKI and Backup Keys` maps `POSTGRES_CA_PEM`, @@ -99,17 +102,20 @@ deleting them. - The same five native `Hetzner App Server makepad` fields map to the exact `DEPLOY_SSH_*` secrets. -- `PostgreSQL · shared Swarm deployment` maps the remote directory, stack, - Catwlk/Le Petit Coin/VIF networks, VIF database and role names, and the VIF - password consumed by the workflow. +- `PostgreSQL · shared Swarm deployment` maps the PostgreSQL remote directory, + stack, Catwlk/VIF networks, VIF database and role names, and the VIF password + consumed by the workflow. +- `Le Petit Coin GitHub Deploy Secrets/DEPLOY_DB_NETWORK` maps to the + `DEPLOY_LE_PETIT_COIN_DB_NETWORK` workflow alias. Historical Fashion or Scraping fields are not consumed by the current workflow and are deliberately absent from the reviewed inventory. ### `staging-brio-identity-db` -- `Hetzner Database Server makepad` supplies native fields `host`, `port`, - `user`, `private_key`, and `known_hosts`, which map to their +- `Hetzner Database Server makepad` supplies canonical custom fields + `DEPLOY_SSH_HOST`, `DEPLOY_SSH_PORT`, `DEPLOY_SSH_USER`, + `DEPLOY_SSH_PRIVATE_KEY`, and `DEPLOY_SSH_KNOWN_HOSTS`, which map to their `BRIO_IDENTITY_DB_DEPLOY_SSH_*` aliases. - `Brio Staging - PostgreSQL` maps only the Keycloak application and backup database passwords. diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index 1f2c9b1..56975a3 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -259,22 +259,29 @@ jq -e ' ([.githubEntries[] | select((.environment == "canary" or .environment == "production") and (.destination | startswith("DEPLOY_SSH_"))) | .item] | unique) == ["Hetzner App Server makepad"] and ([.githubEntries[] | select((.environment == "staging-brio-identity-db" or .environment == "keycloak-cohort-restore") and (.destination | test("SSH_(HOST|PORT|USER|PRIVATE_KEY|KNOWN_HOSTS)$"))) | .item] | unique) == ["Hetzner Database Server makepad"] and ([.githubEntries[] | select(.item == "Hetzner App Server makepad" or .item == "Hetzner Database Server makepad") | {destination, field}] | unique | sort_by(.destination)) == ([ - {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_HOST","field":"host"}, - {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS","field":"known_hosts"}, - {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PORT","field":"port"}, - {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY","field":"private_key"}, - {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_USER","field":"user"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_HOST","field":"DEPLOY_SSH_HOST"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PORT","field":"DEPLOY_SSH_PORT"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"destination":"BRIO_IDENTITY_DB_DEPLOY_SSH_USER","field":"DEPLOY_SSH_USER"}, {"destination":"DEPLOY_SSH_HOST","field":"host"}, {"destination":"DEPLOY_SSH_KNOWN_HOSTS","field":"known_hosts"}, {"destination":"DEPLOY_SSH_PORT","field":"port"}, {"destination":"DEPLOY_SSH_PRIVATE_KEY","field":"private_key"}, {"destination":"DEPLOY_SSH_USER","field":"user"}, - {"destination":"KEYCLOAK_COHORT_DB_SSH_HOST","field":"host"}, - {"destination":"KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS","field":"known_hosts"}, - {"destination":"KEYCLOAK_COHORT_DB_SSH_PORT","field":"port"}, - {"destination":"KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY","field":"private_key"}, - {"destination":"KEYCLOAK_COHORT_DB_SSH_USER","field":"user"} + {"destination":"KEYCLOAK_COHORT_DB_SSH_HOST","field":"DEPLOY_SSH_HOST"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS","field":"DEPLOY_SSH_KNOWN_HOSTS"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_PORT","field":"DEPLOY_SSH_PORT"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY","field":"DEPLOY_SSH_PRIVATE_KEY"}, + {"destination":"KEYCLOAK_COHORT_DB_SSH_USER","field":"DEPLOY_SSH_USER"} ] | sort_by(.destination)) and + ([.githubEntries[] | select(.destination == "DEPLOY_LE_PETIT_COIN_DB_NETWORK") | {environment, item, field}] | sort_by(.environment)) == [ + {"environment":"canary","item":"Le Petit Coin GitHub Deploy Secrets","field":"DEPLOY_DB_NETWORK"}, + {"environment":"production","item":"Le Petit Coin GitHub Deploy Secrets","field":"DEPLOY_DB_NETWORK"} + ] and + ([.githubEntries[] | select(.destination == "DEPLOY_BRIO_STAGING_DB_NETWORK") | {environment, item, field}]) == [ + {"environment":"canary","item":"Brio Staging - PostgreSQL","field":"DEPLOY_BRIO_STAGING_DB_NETWORK"} + ] and ([.githubEntries[] | select(.item == "Brio Staging - PKI and Backup Keys" and (.destination | endswith("_PEM"))) | [.environment, .destination]] | sort) == ([ ["canary", "BRIO_BACKUP_RECIPIENT_CERT_PEM"], diff --git a/scripts/validate-credential-inventory-contract.py b/scripts/validate-credential-inventory-contract.py index 9351cf2..f159bb3 100755 --- a/scripts/validate-credential-inventory-contract.py +++ b/scripts/validate-credential-inventory-contract.py @@ -36,7 +36,13 @@ def github(item: str, **destinations: str) -> set[tuple[str, str, str, str]]: DEPLOY_REMOTE_DIR="DEPLOY_REMOTE_DIR", DEPLOY_STACK_NAME="DEPLOY_STACK_NAME", DEPLOY_CATWLK_DB_NETWORK="DEPLOY_CATWLK_DB_NETWORK", - DEPLOY_LE_PETIT_COIN_DB_NETWORK="DEPLOY_LE_PETIT_COIN_DB_NETWORK", + ) + | github( + "Le Petit Coin GitHub Deploy Secrets", + DEPLOY_LE_PETIT_COIN_DB_NETWORK="DEPLOY_DB_NETWORK", + ) + | github( + "Brio Staging - PostgreSQL", DEPLOY_BRIO_STAGING_DB_NETWORK="DEPLOY_BRIO_STAGING_DB_NETWORK", ) | github( @@ -67,21 +73,24 @@ def github(item: str, **destinations: str) -> set[tuple[str, str, str, str]]: DEPLOY_REMOTE_DIR="DEPLOY_REMOTE_DIR", DEPLOY_STACK_NAME="DEPLOY_STACK_NAME", DEPLOY_CATWLK_DB_NETWORK="DEPLOY_CATWLK_DB_NETWORK", - DEPLOY_LE_PETIT_COIN_DB_NETWORK="DEPLOY_LE_PETIT_COIN_DB_NETWORK", DEPLOY_VIF_DB_NETWORK="DEPLOY_VIF_DB_NETWORK", DEPLOY_VIF_DB_NAME="DEPLOY_VIF_DB_NAME", DEPLOY_VIF_DB_USER="DEPLOY_VIF_DB_USER", DEPLOY_VIF_DB_PASSWORD="DEPLOY_VIF_DB_PASSWORD", ) + | github( + "Le Petit Coin GitHub Deploy Secrets", + DEPLOY_LE_PETIT_COIN_DB_NETWORK="DEPLOY_DB_NETWORK", + ) ), "staging-brio-identity-db": ( github( "Hetzner Database Server makepad", - BRIO_IDENTITY_DB_DEPLOY_SSH_HOST="host", - BRIO_IDENTITY_DB_DEPLOY_SSH_PORT="port", - BRIO_IDENTITY_DB_DEPLOY_SSH_USER="user", - BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY="private_key", - BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS="known_hosts", + BRIO_IDENTITY_DB_DEPLOY_SSH_HOST="DEPLOY_SSH_HOST", + BRIO_IDENTITY_DB_DEPLOY_SSH_PORT="DEPLOY_SSH_PORT", + BRIO_IDENTITY_DB_DEPLOY_SSH_USER="DEPLOY_SSH_USER", + BRIO_IDENTITY_DB_DEPLOY_SSH_PRIVATE_KEY="DEPLOY_SSH_PRIVATE_KEY", + BRIO_IDENTITY_DB_DEPLOY_SSH_KNOWN_HOSTS="DEPLOY_SSH_KNOWN_HOSTS", ) | github( "Brio Staging - PostgreSQL", @@ -118,11 +127,11 @@ def github(item: str, **destinations: str) -> set[tuple[str, str, str, str]]: ) | github( "Hetzner Database Server makepad", - KEYCLOAK_COHORT_DB_SSH_HOST="host", - KEYCLOAK_COHORT_DB_SSH_PORT="port", - KEYCLOAK_COHORT_DB_SSH_USER="user", - KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY="private_key", - KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS="known_hosts", + KEYCLOAK_COHORT_DB_SSH_HOST="DEPLOY_SSH_HOST", + KEYCLOAK_COHORT_DB_SSH_PORT="DEPLOY_SSH_PORT", + KEYCLOAK_COHORT_DB_SSH_USER="DEPLOY_SSH_USER", + KEYCLOAK_COHORT_DB_SSH_PRIVATE_KEY="DEPLOY_SSH_PRIVATE_KEY", + KEYCLOAK_COHORT_DB_SSH_KNOWN_HOSTS="DEPLOY_SSH_KNOWN_HOSTS", ) | github( "Makepad Docker Hardened Images", diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 8562cf6..407240c 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -790,7 +790,7 @@ require( require(all(entry.get("boundary") in {"host-root-file", "host-root-setting", "operator-stdin", "operator-verification"} for entry in non_github_entries), "Non-GitHub credential boundary is invalid.") for canonical_item in { "Hetzner App Server makepad", "Hetzner Database Server makepad", - "Brio Staging - PostgreSQL", + "Brio Staging - PostgreSQL", "Le Petit Coin GitHub Deploy Secrets", "PostgreSQL · shared Swarm deployment", pki_item, "PostgreSQL · Brio identity release orchestrator", "PostgreSQL · Keycloak cohort source reader", "Makepad Docker Hardened Images", @@ -812,18 +812,23 @@ require( }, "Shared-Swarm deployment credentials must target the application Swarm host.", ) -native_ssh_field_by_suffix = { +app_native_ssh_field_by_suffix = { "HOST": "host", "PORT": "port", "USER": "user", "PRIVATE_KEY": "private_key", "KNOWN_HOSTS": "known_hosts", } for entry in github_entries: if entry.get("item") not in {"Hetzner App Server makepad", "Hetzner Database Server makepad"}: continue - suffix = next((suffix for suffix in native_ssh_field_by_suffix if entry["destination"].endswith(f"SSH_{suffix}")), None) + suffix = next((suffix for suffix in app_native_ssh_field_by_suffix if entry["destination"].endswith(f"SSH_{suffix}")), None) require(suffix is not None, f"Unexpected SSH destination {entry['destination']}.") + expected_source = ( + app_native_ssh_field_by_suffix[suffix] + if entry["item"] == "Hetzner App Server makepad" + else f"DEPLOY_SSH_{suffix}" + ) require( - entry.get("field") == native_ssh_field_by_suffix[suffix], - f"SSH destination {entry['destination']} must use its native Proton source field.", + entry.get("field") == expected_source, + f"SSH destination {entry['destination']} must use its host-specific canonical Proton source field.", ) for required in ( "--sync requires one explicit --environment", From cc5075fcc5e26c5a7a3171de25f4451538a92658 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 08:31:53 +0200 Subject: [PATCH 12/16] fix(ci): qualify postgres runner groups --- .github/workflows/deploy-brio-identity-db.yml | 2 +- .github/workflows/manual-deploy.yml | 2 +- .github/workflows/release-brio-identity-db.yml | 2 +- .github/workflows/verify-keycloak-cohort-restores.yml | 2 +- scripts/validate-postgres-config.sh | 6 ++++-- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-brio-identity-db.yml b/.github/workflows/deploy-brio-identity-db.yml index fe82228..cf0f1ea 100644 --- a/.github/workflows/deploy-brio-identity-db.yml +++ b/.github/workflows/deploy-brio-identity-db.yml @@ -23,7 +23,7 @@ permissions: jobs: deploy: runs-on: - group: Postgres Deploy + group: org/Postgres Deploy labels: [self-hosted, linux, x64, makepad, makepad-postgres-deploy] environment: staging-brio-identity-db timeout-minutes: 60 diff --git a/.github/workflows/manual-deploy.yml b/.github/workflows/manual-deploy.yml index 52845a1..fa14b50 100644 --- a/.github/workflows/manual-deploy.yml +++ b/.github/workflows/manual-deploy.yml @@ -18,7 +18,7 @@ concurrency: jobs: deploy: runs-on: - group: Postgres Deploy + group: org/Postgres Deploy labels: [self-hosted, linux, x64, makepad, makepad-postgres-deploy] environment: ${{ inputs.environment }} timeout-minutes: 30 diff --git a/.github/workflows/release-brio-identity-db.yml b/.github/workflows/release-brio-identity-db.yml index 47a209e..e8815c2 100644 --- a/.github/workflows/release-brio-identity-db.yml +++ b/.github/workflows/release-brio-identity-db.yml @@ -24,7 +24,7 @@ jobs: attest: name: protected-cross-repository-attestation runs-on: - group: Postgres Release + group: org/Postgres Release labels: [self-hosted, linux, x64, makepad, makepad-postgres-release] environment: release-brio-identity-db timeout-minutes: 45 diff --git a/.github/workflows/verify-keycloak-cohort-restores.yml b/.github/workflows/verify-keycloak-cohort-restores.yml index 1881a1a..b716fcc 100644 --- a/.github/workflows/verify-keycloak-cohort-restores.yml +++ b/.github/workflows/verify-keycloak-cohort-restores.yml @@ -19,7 +19,7 @@ jobs: verify: name: restore-six-databases-and-start-keycloak runs-on: - group: Postgres Release + group: org/Postgres Release labels: [self-hosted, linux, x64, makepad, makepad-postgres-release] environment: keycloak-cohort-restore timeout-minutes: 120 diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 407240c..b344e62 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -934,7 +934,7 @@ for required in ( ): require(required in manual_deploy_workflow, f"Canary workflow is missing secure Brio input/deploy control: {required}") require("makepad-postgres-deploy" in manual_deploy_workflow, "Manual deployment must use the repository-scoped deploy runner label.") -require("group: Postgres Deploy" in manual_deploy_workflow, "Manual deployment must use the protected Postgres Deploy runner group.") +require("group: org/Postgres Deploy" in manual_deploy_workflow, "Manual deployment must use the organization-qualified protected Postgres Deploy runner group.") require('[[ "${GITHUB_REF}" == "refs/heads/main" ]]' in manual_deploy_workflow, "Manual deployment must refuse unreviewed refs.") require("pull_request_target:" in ci_workflow, "PR CI must execute protected-base workflow code.") require("github.event.pull_request.head.repo.full_name == github.repository" in ci_workflow, "PR CI must reject forks.") @@ -1051,11 +1051,12 @@ for required in ( "makepad.brio-db-deployment-evidence.v1", "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", "makepad-postgres-deploy", - "group: Postgres Deploy", + "group: org/Postgres Deploy", ): require(required in identity_workflow, f"Standalone identity DB workflow is missing: {required}") for required in ( "environment: release-brio-identity-db", + "group: org/Postgres Release", "KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN", "verify-brio-database.yml/dispatches", "verify-brio-release-evidence.py postgres-run", @@ -1076,6 +1077,7 @@ for required in ( "workflow_dispatch:", "keycloak_release_sha:", "environment: keycloak-cohort-restore", + "group: org/Postgres Release", "KEYCLOAK_COHORT_SOURCE_TOKEN", "repos/Makepad-fr/keycloak/git/ref/heads/main", "keycloak-cohort-restore-evidence-${{ github.run_id }}-${{ github.run_attempt }}", From dab76b4cbed80484b1160fe4b68c2da7415af34a Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 09:13:43 +0200 Subject: [PATCH 13/16] chore(ci): define GitHub App provider contract --- deploy/github-app-contracts.json | 62 +++++++++++++ scripts/validate-github-provider-contract.py | 92 ++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 deploy/github-app-contracts.json create mode 100644 scripts/validate-github-provider-contract.py diff --git a/deploy/github-app-contracts.json b/deploy/github-app-contracts.json new file mode 100644 index 0000000..82ed36b --- /dev/null +++ b/deploy/github-app-contracts.json @@ -0,0 +1,62 @@ +{ + "schemaVersion": 1, + "owner": { + "login": "Makepad-fr", + "type": "Organization" + }, + "repository": { + "fullName": "Makepad-fr/postgres", + "visibility": "public" + }, + "installation": { + "repositorySelection": "selected", + "repositories": [ + "Makepad-fr/postgres" + ] + }, + "apps": [ + { + "name": "Makepad PostgreSQL CI Checks", + "role": "checks", + "webhookActive": false, + "webhookUrl": null, + "events": [], + "repositoryPermissions": { + "metadata": "read", + "checks": "write" + }, + "organizationPermissions": { + "self_hosted_runners": "read" + } + }, + { + "name": "Makepad PostgreSQL CI Launcher", + "role": "launcher", + "webhookActive": false, + "webhookUrl": null, + "events": [], + "repositoryPermissions": { + "metadata": "read", + "actions": "read", + "contents": "write", + "issues": "write", + "pull_requests": "read" + }, + "organizationPermissions": { + "self_hosted_runners": "write" + } + } + ], + "repositoryVariableBootstrap": { + "protonItem": "PostgreSQL · GitHub repository variable bootstrap", + "tokenField": "repository_variable_admin_token", + "ownerField": "owner", + "expiresAtField": "expires_at", + "owner": "Makepad-fr", + "repository": "Makepad-fr/postgres", + "repositoryPermissions": { + "metadata": "read", + "variables": "write" + } + } +} diff --git a/scripts/validate-github-provider-contract.py b/scripts/validate-github-provider-contract.py new file mode 100644 index 0000000..d3d4945 --- /dev/null +++ b/scripts/validate-github-provider-contract.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Fail closed unless the reviewed PostgreSQL GitHub provider contract is exact.""" + +from __future__ import annotations + +import json +import pathlib +import stat +import sys +from typing import Any, NoReturn + + +EXPECTED: dict[str, Any] = { + "schemaVersion": 1, + "owner": {"login": "Makepad-fr", "type": "Organization"}, + "repository": {"fullName": "Makepad-fr/postgres", "visibility": "public"}, + "installation": { + "repositorySelection": "selected", + "repositories": ["Makepad-fr/postgres"], + }, + "apps": [ + { + "name": "Makepad PostgreSQL CI Checks", + "role": "checks", + "webhookActive": False, + "webhookUrl": None, + "events": [], + "repositoryPermissions": {"metadata": "read", "checks": "write"}, + "organizationPermissions": {"self_hosted_runners": "read"}, + }, + { + "name": "Makepad PostgreSQL CI Launcher", + "role": "launcher", + "webhookActive": False, + "webhookUrl": None, + "events": [], + "repositoryPermissions": { + "metadata": "read", + "actions": "read", + "contents": "write", + "issues": "write", + "pull_requests": "read", + }, + "organizationPermissions": {"self_hosted_runners": "write"}, + }, + ], + "repositoryVariableBootstrap": { + "protonItem": "PostgreSQL · GitHub repository variable bootstrap", + "tokenField": "repository_variable_admin_token", + "ownerField": "owner", + "expiresAtField": "expires_at", + "owner": "Makepad-fr", + "repository": "Makepad-fr/postgres", + "repositoryPermissions": {"metadata": "read", "variables": "write"}, + }, +} + + +def fail(message: str) -> NoReturn: + raise SystemExit(f"GitHub provider contract violation: {message}") + + +def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + fail(f"duplicate JSON key {key!r}") + result[key] = value + return result + + +def main(arguments: list[str]) -> int: + if len(arguments) != 1: + fail("expected exactly one contract path") + path = pathlib.Path(arguments[0]) + try: + metadata = path.lstat() + except OSError as error: + fail(f"contract cannot be inspected: {type(error).__name__}") + if not stat.S_ISREG(metadata.st_mode) or path.is_symlink() or metadata.st_size > 65536: + fail("contract must be a bounded regular non-symlink file") + try: + actual = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + fail(f"contract cannot be parsed: {type(error).__name__}") + if actual != EXPECTED: + fail("owner, App, webhook, event, installation, permission, or bootstrap scope drifted") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 9ebe780eed0066991d5bde2a9fd53e0bb76a0d98 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 09:13:43 +0200 Subject: [PATCH 14/16] fix(credentials): bind repository variable bootstrap --- README.md | 20 ++++++- deploy/credential-inventory.json | 5 +- docs/credential-sync.md | 13 +++++ scripts/run-ci.sh | 1 + scripts/sync-github-environments.sh | 52 ++++++++++++++++-- scripts/test-sync-github-environments.sh | 53 +++++++++++++++++++ .../validate-credential-inventory-contract.py | 3 ++ scripts/validate-postgres-config.sh | 12 +++-- 8 files changed, 148 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c8ad489..669d760 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ GitHub environment variables. The exact Brio inventory is: | `PostgreSQL · PR Checks App` | `postgres-ci-attestation` and repository policy | secret `POSTGRES_PR_CHECK_APP_PRIVATE_KEY` in the environment; public repository variable `POSTGRES_PR_CHECK_APP_ID` | | `PostgreSQL · JIT Launcher App` | repository policy only | public repository variable `POSTGRES_CI_LAUNCHER_APP_SENDER_ID`; private App fields remain on the controller host only | | `PostgreSQL · JIT hypervisor attestation` | repository policy only | public repository variables `POSTGRES_CI_ATTESTATION_PUBLIC_KEY` and `POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256`; the signing key remains on the hypervisor only | +| `PostgreSQL · GitHub repository variable bootstrap` | operator workstation only | field `repository_variable_admin_token` is supplied process-locally to `gh` only during the explicit four-variable sync; `owner` and `expires_at` remain operator verification records | The identity DB hostname and Keycloak source CIDR are protected environment variables. The current shared-Swarm workflow still consumes its remote path, @@ -268,7 +269,12 @@ Never place values in command arguments, temporary files, shell history, Actions logs, or issue text. Mirror the four non-secret repository trust anchors only through the explicitly bounded command below; it validates their provider IDs, lowercase SHA-256 digest, and Ed25519 public key before stdin-only -writes, then compares two GitHub read-backs exactly with Proton: +writes, then compares two GitHub read-backs exactly with Proton. The helper +refuses ambient GitHub authentication for those mutations: first create the +canonical `PostgreSQL · GitHub repository variable bootstrap` Proton item with +`repository_variable_admin_token`, `owner`, and `expires_at`. Its short-lived +fine-grained credential is limited to `Makepad-fr/postgres` with repository +Variables write and Metadata read, and is revoked after the exact read-back: ```bash ./scripts/sync-github-environments.sh --sync-repository-variables \ @@ -412,13 +418,23 @@ must trigger the independent host alert service and no blind retry occurs. Long-lived CI controller material is canonical in Proton Pass before it is installed at its narrow runtime boundary: +The provider setup itself is pinned in +[`deploy/github-app-contracts.json`](deploy/github-app-contracts.json). Create +the organization-owned Apps with the exact display names +`Makepad PostgreSQL CI Checks` and `Makepad PostgreSQL CI Launcher`, disable +and empty both webhooks, subscribe to no events, and install each using +selected-repository access to `Makepad-fr/postgres` only. The public repository +runner groups continue to allow public repositories, but remain selected to +this one exact repository and protected workflow set. + | Proton Pass item | Exact runtime fields and authority | | --- | --- | -| `PostgreSQL · PR Checks App` | protected `postgres-ci-attestation` environment variable `POSTGRES_PR_CHECK_APP_ID` and secret `POSTGRES_PR_CHECK_APP_PRIVATE_KEY`; App installed only on this repository with Metadata read, Actions read, Checks write, and organization self-hosted-runners read | +| `PostgreSQL · PR Checks App` | repository variable `POSTGRES_PR_CHECK_APP_ID` and protected `postgres-ci-attestation` secret `POSTGRES_PR_CHECK_APP_PRIVATE_KEY`; App installed only on this repository with Metadata read, Checks write, and organization self-hosted-runners read; it has no Actions permission | | `PostgreSQL · JIT Launcher App` | root-only hypervisor `POSTGRES_CI_LAUNCHER_APP_ID`, `POSTGRES_CI_LAUNCHER_APP_INSTALLATION_ID`, and mode-0400 `POSTGRES_CI_LAUNCHER_APP_PRIVATE_KEY_FILE`; repository variable `POSTGRES_CI_LAUNCHER_APP_SENDER_ID`; App installed only on this repository with Metadata read, Actions read, Contents write for repository dispatch, Issues write for secondary alerts, Pull requests read, and organization self-hosted-runners write | | `PostgreSQL · JIT hypervisor attestation` | root-only mode-0400 `POSTGRES_CI_ATTESTATION_PRIVATE_KEY_FILE`; repository variable `POSTGRES_CI_ATTESTATION_PUBLIC_KEY`; reviewed repository variable and root-only value `POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256`/`POSTGRES_CI_BASE_IMAGE_SHA256` | | `PostgreSQL · runner-group controller` | administrator workstation input streamed to `scripts/configure-postgres-ci-runner-group.sh`; organization runner-group write and repository Metadata read only, never installed on a runner or hypervisor | | `PostgreSQL · CI hypervisor alert` | root-only host alert URL file consumed only by the systemd `OnFailure` handler; never mirrored to GitHub Actions | +| `PostgreSQL · GitHub repository variable bootstrap` | one-time operator credential used only by `--sync-repository-variables`; exact repository Variables write and Metadata read, with no Actions, Contents, Administration, Environments, Secrets, or organization permission | The Launcher and Checks Apps are different installations and keys. Store their numeric IDs, installation IDs, public-key fingerprints, approved base-image diff --git a/deploy/credential-inventory.json b/deploy/credential-inventory.json index b726f00..5765236 100644 --- a/deploy/credential-inventory.json +++ b/deploy/credential-inventory.json @@ -76,6 +76,9 @@ {"boundary":"operator-verification","requirement":"required","destination":"PostgreSQL Ed25519 public-key fingerprint","item":"PostgreSQL · JIT hypervisor attestation","field":"public_key_fingerprint"}, {"boundary":"host-root-setting","requirement":"required","destination":"/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_BASE_IMAGE_SHA256","item":"PostgreSQL · JIT hypervisor attestation","field":"qcow2_sha256"}, {"boundary":"operator-stdin","requirement":"required","destination":"scripts/configure-postgres-ci-runner-group.sh standard input","item":"PostgreSQL · runner-group controller","field":"organization_runner_admin_token"}, - {"boundary":"host-root-file","requirement":"required","destination":"POSTGRES_HOST_ALERT_URL_FILE","item":"PostgreSQL · CI hypervisor alert","field":"url"} + {"boundary":"host-root-file","requirement":"required","destination":"POSTGRES_HOST_ALERT_URL_FILE","item":"PostgreSQL · CI hypervisor alert","field":"url"}, + {"boundary":"operator-process-auth","requirement":"required","destination":"scripts/sync-github-environments.sh --sync-repository-variables process-local GH_TOKEN","item":"PostgreSQL · GitHub repository variable bootstrap","field":"repository_variable_admin_token"}, + {"boundary":"operator-verification","requirement":"required","destination":"PostgreSQL repository-variable bootstrap expiry","item":"PostgreSQL · GitHub repository variable bootstrap","field":"expires_at"}, + {"boundary":"operator-verification","requirement":"required","destination":"PostgreSQL repository-variable bootstrap owner","item":"PostgreSQL · GitHub repository variable bootstrap","field":"owner"} ] } diff --git a/docs/credential-sync.md b/docs/credential-sync.md index 73ed4c0..6e95856 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -6,6 +6,13 @@ protected GitHub environments, the four public repository policy variables, and the root/operator boundaries used by the disposable-runner control plane. Repository code never creates, rotates, or deletes a credential. +`deploy/github-app-contracts.json` separately pins both organization-owned App +names, owner, exact selected-repository installation, disabled and empty +webhooks, empty event subscriptions, and least-privilege permission maps. It +also pins the canonical repository-variable bootstrap item and its exact +Variables-write/Metadata-read scope. `scripts/validate-github-provider-contract.py` +rejects any drift before credential synchronization reaches a provider call. + `scripts/validate-credential-inventory-contract.py` independently pins every environment/kind/destination/Proton-item/field tuple, plus every repository and non-GitHub tuple. Both check and sync modes run it before the first provider @@ -45,6 +52,12 @@ operation: --confirm Makepad-fr/postgres:repository-variables ``` +That operation reads its process-local GitHub credential only from Proton item +`PostgreSQL · GitHub repository variable bootstrap`, field +`repository_variable_admin_token`; it never uses the ambient `gh` session for +repository-variable writes or exact-value read-back. Record `owner` and +`expires_at` on the same item and revoke the credential after reconciliation. + Sync mode rejects an omitted or arbitrary environment. Before its first field read it proves this intentionally public repository is active and uses `main` as its default branch; proves the selected environment has exactly one custom diff --git a/scripts/run-ci.sh b/scripts/run-ci.sh index 3c0ca92..96f251a 100755 --- a/scripts/run-ci.sh +++ b/scripts/run-ci.sh @@ -51,6 +51,7 @@ for source in ( "scripts/reconcile-github-environment-main-policy.py", "scripts/test-github-environment-main-policy.py", "scripts/validate-credential-inventory-contract.py", + "scripts/validate-github-provider-contract.py", "scripts/validate-repository-trust-anchor.py", ): ast.parse(Path(source).read_text(), filename=source) diff --git a/scripts/sync-github-environments.sh b/scripts/sync-github-environments.sh index d0f4721..67604b6 100755 --- a/scripts/sync-github-environments.sh +++ b/scripts/sync-github-environments.sh @@ -17,9 +17,13 @@ repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) readonly repo_root readonly inventory=${repo_root}/deploy/credential-inventory.json readonly inventory_contract_validator=${repo_root}/scripts/validate-credential-inventory-contract.py +readonly provider_contract=${repo_root}/deploy/github-app-contracts.json +readonly provider_contract_validator=${repo_root}/scripts/validate-github-provider-contract.py readonly repository_anchor_validator=${repo_root}/scripts/validate-repository-trust-anchor.py readonly environment_policy_reconciler=${repo_root}/scripts/reconcile-github-environment-main-policy.py readonly max_value_bytes=49152 +readonly repository_bootstrap_item='PostgreSQL · GitHub repository variable bootstrap' +readonly repository_bootstrap_field=repository_variable_admin_token usage() { printf '%s\n' \ @@ -93,12 +97,18 @@ done [[ -f "${inventory}" && ! -L "${inventory}" ]] || die 'credential inventory is missing or is a symbolic link' [[ -f "${inventory_contract_validator}" && ! -L "${inventory_contract_validator}" ]] || \ die 'credential inventory contract validator is missing or is a symbolic link' +[[ -f "${provider_contract}" && ! -L "${provider_contract}" ]] || \ + die 'GitHub provider contract is missing or is a symbolic link' +[[ -f "${provider_contract_validator}" && ! -L "${provider_contract_validator}" ]] || \ + die 'GitHub provider contract validator is missing or is a symbolic link' [[ -f "${repository_anchor_validator}" && ! -L "${repository_anchor_validator}" ]] || \ die 'repository trust-anchor validator is missing or is a symbolic link' [[ -f "${environment_policy_reconciler}" && ! -L "${environment_policy_reconciler}" ]] || \ die 'environment protection reconciler is missing or is a symbolic link' PYTHONDONTWRITEBYTECODE=1 python3 "${inventory_contract_validator}" "${inventory}" || \ die 'credential inventory does not match the immutable reviewed contract' +PYTHONDONTWRITEBYTECODE=1 python3 "${provider_contract_validator}" "${provider_contract}" || \ + die 'GitHub provider settings do not match the immutable reviewed contract' tmp_base=${TMPDIR:-/tmp} [[ -d "${tmp_base}" && ! -L "${tmp_base}" ]] || die 'temporary directory base is unsafe' @@ -129,6 +139,7 @@ declare -a repository_destination=() declare -a repository_item=() declare -a repository_field=() declare -a repository_source_values=() +repository_variable_bootstrap_token= cleanup() { local index @@ -138,6 +149,7 @@ cleanup() { for index in "${!repository_source_values[@]}"; do unset 'repository_source_values[index]' done + unset repository_variable_bootstrap_token if [[ -n "${status_root:-}" && "${status_root}" == "${tmp_base}/postgres-credential-sync."* && -d "${status_root}" && ! -L "${status_root}" ]]; then find "${status_root}" -depth -mindepth 1 -delete rmdir -- "${status_root}" @@ -185,7 +197,7 @@ allowed_kinds = {"secret", "variable"} allowed_requirements = {"required", "optional"} allowed_boundaries = { "host-root-file", "host-root-setting", "operator-stdin", - "operator-verification", + "operator-verification", "operator-process-auth", } public_environment_destinations = { "BRIO_IDENTITY_DB_HOSTNAME", "BRIO_KEYCLOAK_DB_SOURCE_CIDR", @@ -306,7 +318,12 @@ for offset, entry in enumerate(non_github_entries): raise SystemExit(f"duplicate non-GitHub destination: {boundary}/{destination}") seen_non_github.add(identity) non_github_lines.append("\t".join((boundary, requirement, destination, item, field))) - if not selected_environment and not repository_sync: + if (not selected_environment and not repository_sync) or ( + repository_sync + and boundary == "operator-process-auth" + and item == "PostgreSQL · GitHub repository variable bootstrap" + and field == "repository_variable_admin_token" + ): prior = selected_source_requirements.get(item) selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" @@ -367,6 +384,12 @@ repository_variables_in_scope() { [[ "${mode}" == sync-repository-variables || -z "${selected_environment}" || "${selected_environment}" == postgres-ci-attestation ]] } +repository_variable_gh() { + [[ "${mode}" == sync-repository-variables && -n "${repository_variable_bootstrap_token}" ]] || \ + die 'canonical repository-variable bootstrap credential is not loaded' + GH_TOKEN="${repository_variable_bootstrap_token}" GH_PROMPT_DISABLED=1 gh "$@" +} + destination_expected() { local environment=$1 kind=$2 destination=$3 awk -F '\t' -v environment="${environment}" -v kind="${kind}" -v destination="${destination}" \ @@ -562,6 +585,15 @@ fi ulimit -c 0 || die 'could not disable process core dumps before handling credential values' if [[ "${mode}" == sync-repository-variables ]]; then + if ! repository_variable_bootstrap_token=$(pass-cli item view --vault-name "${vault}" \ + --item-title "${repository_bootstrap_item}" --field "${repository_bootstrap_field}" 2>/dev/null); then + die 'canonical Proton repository-variable bootstrap credential is missing or unreadable' + fi + [[ "${repository_variable_bootstrap_token}" =~ ^(github_pat_|ghs_)[A-Za-z0-9_]{20,}$ ]] || \ + die 'canonical Proton repository-variable bootstrap credential has an invalid token shape' + (( ${#repository_variable_bootstrap_token} <= max_value_bytes )) || \ + die 'canonical Proton repository-variable bootstrap credential exceeds the bounded value size' + # Read and semantically validate every public trust anchor before the first # provider write. Values travel to the validator and GitHub only on stdin. for index in "${!repository_destination[@]}"; do @@ -593,15 +625,24 @@ if [[ "${mode}" == sync-repository-variables ]]; then (( unexpected_destinations == 0 )) || die 'a forbidden or unmanaged GitHub name appeared during source preflight' (( missing_required_sources == 0 )) || die 'a required Proton item disappeared during source preflight' + repository_variable_bootstrap_token_readback= + if ! repository_variable_bootstrap_token_readback=$(pass-cli item view --vault-name "${vault}" \ + --item-title "${repository_bootstrap_item}" --field "${repository_bootstrap_field}" 2>/dev/null); then + die 'canonical Proton repository-variable bootstrap credential disappeared during source preflight' + fi + [[ "${repository_variable_bootstrap_token_readback}" == "${repository_variable_bootstrap_token}" ]] || \ + die 'canonical Proton repository-variable bootstrap credential changed during source preflight' + unset repository_variable_bootstrap_token_readback + for index in "${!repository_destination[@]}"; do destination=${repository_destination[index]} [[ -n "${repository_source_values[index]:-}" ]] || continue if ! printf '%s' "${repository_source_values[index]}" | - GH_PROMPT_DISABLED=1 gh variable set "${destination}" --repo "${repository}" >/dev/null 2>&1; then + repository_variable_gh variable set "${destination}" --repo "${repository}" >/dev/null 2>&1; then die "GitHub rejected repository/variable/${destination}" fi readback= - if ! readback=$(GH_PROMPT_DISABLED=1 gh variable get "${destination}" --repo "${repository}" --json value --jq '.value' 2>/dev/null); then + if ! readback=$(repository_variable_gh variable get "${destination}" --repo "${repository}" --json value --jq '.value' 2>/dev/null); then die "GitHub repository-variable read-back failed: ${destination}" fi [[ "${readback}" == "${repository_source_values[index]}" ]] || \ @@ -618,13 +659,14 @@ if [[ "${mode}" == sync-repository-variables ]]; then destination=${repository_destination[index]} [[ -n "${repository_source_values[index]:-}" ]] || continue readback= - if ! readback=$(GH_PROMPT_DISABLED=1 gh variable get "${destination}" --repo "${repository}" --json value --jq '.value' 2>/dev/null); then + if ! readback=$(repository_variable_gh variable get "${destination}" --repo "${repository}" --json value --jq '.value' 2>/dev/null); then die "GitHub final repository-variable read-back failed: ${destination}" fi [[ "${readback}" == "${repository_source_values[index]}" ]] || \ die "GitHub final repository-variable read-back differed from Proton: ${destination}" unset readback 'repository_source_values[index]' done + unset repository_variable_bootstrap_token printf 'SYNC_COMPLETE repository=%s vault=%s scope=repository-variables count=%d\n' \ "${repository}" "${vault}" "${#repository_destination[@]}" exit 0 diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index 56975a3..1677016 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -59,6 +59,13 @@ if [[ "${1:-} ${2:-}" == 'item view' ]]; then exit 0 fi case "${field}" in + repository_variable_admin_token) + if [[ "${FAKE_INVALID_BOOTSTRAP_TOKEN:-0}" == 1 ]]; then + printf 'invalid-bootstrap-token' + else + printf 'github_pat_FAKEPOSTGRESREPOSITORYVARIABLES0001' + fi + ;; bot_user_id) printf '9001' ;; qcow2_sha256) printf '%064d' 0 | tr 0 a ;; ed25519_public_key) @@ -204,6 +211,10 @@ if [[ "${operation}" == list ]]; then fi if [[ "${operation}" == get && "${kind}" == variable && -z "${environment}" && -n "${destination}" ]]; then + [[ "${GH_TOKEN:-}" == 'github_pat_FAKEPOSTGRESREPOSITORYVARIABLES0001' ]] || { + echo 'repository-variable read-back did not use canonical Proton authentication' >&2 + exit 93 + } [[ -f "${FAKE_GITHUB_STATE_DIR}/${destination}" ]] if [[ "${FAKE_READBACK_MISMATCH:-}" == "${destination}" ]]; then printf 'different-readback' @@ -216,6 +227,10 @@ fi [[ "${operation}" == set && -n "${destination}" ]] if [[ -z "${environment}" ]]; then [[ "${kind}" == variable ]] + [[ "${GH_TOKEN:-}" == 'github_pat_FAKEPOSTGRESREPOSITORYVARIABLES0001' ]] || { + echo 'repository-variable write did not use canonical Proton authentication' >&2 + exit 92 + } umask 077 command cat >"${FAKE_GITHUB_STATE_DIR}/${destination}" bytes=$(wc -c <"${FAKE_GITHUB_STATE_DIR}/${destination}" | tr -d '[:space:]') @@ -300,6 +315,14 @@ jq -e ' ([.nonGitHubEntries[] | select(.destination | test("controller.env|private-key|HOST_ALERT"))] | length >= 5) ' "${inventory}" >/dev/null +jq -e ' + ([.nonGitHubEntries[] | select(.item == "PostgreSQL · GitHub repository variable bootstrap") | [.boundary, .field]] | sort) == ([ + ["operator-process-auth", "repository_variable_admin_token"], + ["operator-verification", "expires_at"], + ["operator-verification", "owner"] + ] | sort) +' "${inventory}" >/dev/null + : >"${audit_log}" run_helper 0 "${helper}" --check grep -Fq 'NON_GITHUB_DESTINATION boundary=host-root-file' <<<"${output}" @@ -323,6 +346,23 @@ grep -Fq -- '--sync-repository-variables does not accept --environment' <<<"${ou run_helper 1 "${helper}" --check --confirm Makepad-fr/postgres:repository-variables grep -Fq -- '--confirm is accepted only with --sync-repository-variables' <<<"${output}" +: >"${audit_log}" +run_helper 1 env FAKE_MISSING_ITEM='PostgreSQL · GitHub repository variable bootstrap' \ + "${helper}" --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables +grep -Fq 'title=PostgreSQL · GitHub repository variable bootstrap requirement=required status=missing' <<<"${output}" +assert_no_value_read_or_write + +: >"${audit_log}" +run_helper 1 env FAKE_INVALID_BOOTSTRAP_TOKEN=1 \ + "${helper}" --sync-repository-variables \ + --confirm Makepad-fr/postgres:repository-variables +grep -Fq 'bootstrap credential has an invalid token shape' <<<"${output}" +if grep -Fq 'gh-set ' "${audit_log}" || grep -Fq 'invalid-bootstrap-token' <<<"${output}"; then + echo 'invalid repository-variable bootstrap token was written or printed' >&2 + exit 1 +fi + : >"${audit_log}" run_helper 1 env FAKE_MISSING_DESTINATION=KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN \ "${helper}" --check --environment release-brio-identity-db @@ -491,8 +531,21 @@ cp "${repo_root}/scripts/reconcile-github-environment-main-policy.py" \ "${candidate_root}/scripts/reconcile-github-environment-main-policy.py" cp "${repo_root}/scripts/validate-credential-inventory-contract.py" \ "${candidate_root}/scripts/validate-credential-inventory-contract.py" +cp "${repo_root}/scripts/validate-github-provider-contract.py" \ + "${candidate_root}/scripts/validate-github-provider-contract.py" +cp "${repo_root}/deploy/github-app-contracts.json" \ + "${candidate_root}/deploy/github-app-contracts.json" chmod 0755 "${candidate_root}/scripts/sync-github-environments.sh" +jq '.apps[0].events = ["push"]' "${repo_root}/deploy/github-app-contracts.json" \ + >"${candidate_root}/deploy/github-app-contracts.json" +: >"${audit_log}" +run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check --environment canary +grep -Fq 'GitHub provider settings do not match the immutable reviewed contract' <<<"${output}" +[[ ! -s "${audit_log}" ]] +cp "${repo_root}/deploy/github-app-contracts.json" \ + "${candidate_root}/deploy/github-app-contracts.json" + jq '(.githubEntries[] | select(.destination == "BRIO_IDENTITY_DB_HOSTNAME")).kind = "secret"' \ "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" : >"${audit_log}" diff --git a/scripts/validate-credential-inventory-contract.py b/scripts/validate-credential-inventory-contract.py index f159bb3..a3d7535 100755 --- a/scripts/validate-credential-inventory-contract.py +++ b/scripts/validate-credential-inventory-contract.py @@ -180,6 +180,9 @@ def github(item: str, **destinations: str) -> set[tuple[str, str, str, str]]: ("host-root-setting", "/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_BASE_IMAGE_SHA256", "PostgreSQL · JIT hypervisor attestation", "qcow2_sha256"), ("operator-stdin", "scripts/configure-postgres-ci-runner-group.sh standard input", "PostgreSQL · runner-group controller", "organization_runner_admin_token"), ("host-root-file", "POSTGRES_HOST_ALERT_URL_FILE", "PostgreSQL · CI hypervisor alert", "url"), + ("operator-process-auth", "scripts/sync-github-environments.sh --sync-repository-variables process-local GH_TOKEN", "PostgreSQL · GitHub repository variable bootstrap", "repository_variable_admin_token"), + ("operator-verification", "PostgreSQL repository-variable bootstrap expiry", "PostgreSQL · GitHub repository variable bootstrap", "expires_at"), + ("operator-verification", "PostgreSQL repository-variable bootstrap owner", "PostgreSQL · GitHub repository variable bootstrap", "owner"), } diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index b344e62..22350e8 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -12,6 +12,9 @@ repo_root=$(cd "${script_dir}/.." && pwd) PYTHONDONTWRITEBYTECODE=1 python3 \ "${repo_root}/scripts/validate-credential-inventory-contract.py" \ "${repo_root}/deploy/credential-inventory.json" +PYTHONDONTWRITEBYTECODE=1 python3 \ + "${repo_root}/scripts/validate-github-provider-contract.py" \ + "${repo_root}/deploy/github-app-contracts.json" REPO_ROOT="${repo_root}" python3 - <<'PY' import json @@ -787,7 +790,7 @@ require( }, "Brio PKI fields must match their exact workflow destinations.", ) -require(all(entry.get("boundary") in {"host-root-file", "host-root-setting", "operator-stdin", "operator-verification"} for entry in non_github_entries), "Non-GitHub credential boundary is invalid.") +require(all(entry.get("boundary") in {"host-root-file", "host-root-setting", "operator-stdin", "operator-verification", "operator-process-auth"} for entry in non_github_entries), "Non-GitHub credential boundary is invalid.") for canonical_item in { "Hetzner App Server makepad", "Hetzner Database Server makepad", "Brio Staging - PostgreSQL", "Le Petit Coin GitHub Deploy Secrets", @@ -796,6 +799,7 @@ for canonical_item in { "PostgreSQL · Keycloak cohort source reader", "Makepad Docker Hardened Images", "PostgreSQL · PR Checks App", "PostgreSQL · JIT Launcher App", "PostgreSQL · JIT hypervisor attestation", + "PostgreSQL · GitHub repository variable bootstrap", }: require(canonical_item in readme, f"README credential inventory is missing canonical Proton item {canonical_item}.") ssh_source_by_environment = { @@ -837,13 +841,15 @@ for required in ( "pass-cli item view", 'gh secret set "${destination}" --repo "${repository}" --env "${environment}"', 'gh variable set "${destination}" --repo "${repository}" --env "${environment}"', - 'gh variable set "${destination}" --repo "${repository}"', - 'gh variable get "${destination}" --repo "${repository}" --json value', + 'repository_variable_gh variable set "${destination}" --repo "${repository}"', + 'repository_variable_gh variable get "${destination}" --repo "${repository}" --json value', "REPOSITORY name=%s policy=public-active-main", 'status=forbidden', "environment_policy_reconciler", "protection=exact-reviewed-matrix", "inventory_contract_validator", + "provider_contract_validator", + "repository_variable_bootstrap_token", ): require(required in credential_sync, f"Credential sync helper is missing fail-closed control: {required}") for forbidden in ("gh secret delete", "gh variable delete", "pass-cli item delete"): From 442009967c7ff6344b49d376f46c251943789dab Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 09:17:52 +0200 Subject: [PATCH 15/16] test(credentials): cover provider contract drift --- scripts/test-sync-github-environments.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index 1677016..596fd4d 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -535,6 +535,7 @@ cp "${repo_root}/scripts/validate-github-provider-contract.py" \ "${candidate_root}/scripts/validate-github-provider-contract.py" cp "${repo_root}/deploy/github-app-contracts.json" \ "${candidate_root}/deploy/github-app-contracts.json" +cp "${inventory}" "${candidate_root}/deploy/credential-inventory.json" chmod 0755 "${candidate_root}/scripts/sync-github-environments.sh" jq '.apps[0].events = ["push"]' "${repo_root}/deploy/github-app-contracts.json" \ From 411cd2a26adf3dce48d7c630e03fa6e8b6559314 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Sat, 5 Sep 2026 10:08:25 +0200 Subject: [PATCH 16/16] fix(credentials): preserve scoped staging names --- README.md | 8 ++ deploy/credential-inventory.json | 22 ++++- docs/credential-sync.md | 30 ++++++- scripts/sync-github-environments.sh | 90 +++++++++++++++++-- scripts/test-sync-github-environments.sh | 34 +++++++ .../validate-credential-inventory-contract.py | 49 +++++++++- scripts/validate-postgres-config.sh | 48 +++++++++- 7 files changed, 268 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 669d760..e6994e2 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ GitHub environment variables. The exact Brio inventory is: | `PostgreSQL · shared Swarm deployment` | `canary` and `production` | current workflow-compatible protected fields for the PostgreSQL remote directory, stack, Catwlk network, and production-only VIF database name, role, network, and password; exact destinations are in `deploy/credential-inventory.json` | | `Le Petit Coin GitHub Deploy Secrets` | `canary` and `production` | canonical `DEPLOY_DB_NETWORK` maps to the PostgreSQL workflow alias `DEPLOY_LE_PETIT_COIN_DB_NETWORK`, keeping both stacks on the same application-owned database overlay | | `Brio Staging - PostgreSQL` | `canary`; Keycloak passwords only in `staging-brio-identity-db` | `canary` secrets `DEPLOY_BRIO_STAGING_DB_NETWORK`, `POSTGRES_CANARY_SUPERUSER_PASSWORD`, `BRIO_STAGING_DB_PASSWORD`, and `BRIO_STAGING_BACKUP_DB_PASSWORD`; only `KEYCLOAK_BRIO_STAGING_DB_PASSWORD` and `KEYCLOAK_BRIO_STAGING_BACKUP_DB_PASSWORD` are mirrored to `staging-brio-identity-db` | +| Name-only retained destinations | No Proton field and no write authority | Existing `staging-brio-identity-db` secrets `BRIO_STAGING_DB_PASSWORD` and `BRIO_STAGING_BACKUP_DB_PASSWORD`, plus variable `POSTGRES_HOST_COMPOSE_PROJECT`; report and preserve pending an explicitly authorized provider cleanup | | `Brio Staging - PKI and Backup Keys` | `canary`; recipient certificate only in `staging-brio-identity-db` | `canary` secrets `POSTGRES_CA_PEM`, `POSTGRES_SERVER_CERT_PEM`, `POSTGRES_SERVER_KEY_PEM`, and `BRIO_BACKUP_RECIPIENT_CERT_PEM`; only `BRIO_BACKUP_RECIPIENT_CERT_PEM` is mirrored to `staging-brio-identity-db` | | `PostgreSQL · Brio identity release orchestrator` | `release-brio-identity-db` | secret `KEYCLOAK_RELEASE_ORCHESTRATOR_TOKEN` | | `PostgreSQL · Keycloak cohort source reader` | `keycloak-cohort-restore` | secret `KEYCLOAK_COHORT_SOURCE_TOKEN` | @@ -248,6 +249,13 @@ GitHub environment variables. The exact Brio inventory is: | `PostgreSQL · JIT hypervisor attestation` | repository policy only | public repository variables `POSTGRES_CI_ATTESTATION_PUBLIC_KEY` and `POSTGRES_CI_APPROVED_BASE_IMAGE_SHA256`; the signing key remains on the hypervisor only | | `PostgreSQL · GitHub repository variable bootstrap` | operator workstation only | field `repository_variable_admin_token` is supplied process-locally to `gh` only during the explicit four-variable sync; `owner` and `expires_at` remain operator verification records | +The first two name-only entries are environment-scope duplicates: their active +workflow destinations are the identically named `canary` secrets, while the +identity workflow consumes only the Keycloak-role passwords. The Compose +project selector has no workflow consumer because the DB-host deploy pins +project `postgres` in code. The sync helper never reads, writes, or deletes +these three names, and it still rejects every other unlisted destination. + The identity DB hostname and Keycloak source CIDR are protected environment variables. The current shared-Swarm workflow still consumes its remote path, stack, network, VIF database name, and VIF role through the existing protected diff --git a/deploy/credential-inventory.json b/deploy/credential-inventory.json index 5765236..2802145 100644 --- a/deploy/credential-inventory.json +++ b/deploy/credential-inventory.json @@ -1,7 +1,27 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "repository": "Makepad-fr/postgres", "vault": "Makepad", + "retainedEnvironmentDestinations": [ + { + "environment": "staging-brio-identity-db", + "kind": "secret", + "destination": "BRIO_STAGING_BACKUP_DB_PASSWORD", + "classification": "scope-duplicate-canary-consumer" + }, + { + "environment": "staging-brio-identity-db", + "kind": "secret", + "destination": "BRIO_STAGING_DB_PASSWORD", + "classification": "scope-duplicate-canary-consumer" + }, + { + "environment": "staging-brio-identity-db", + "kind": "variable", + "destination": "POSTGRES_HOST_COMPOSE_PROJECT", + "classification": "obsolete-fixed-in-code" + } + ], "githubEntries": [ {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_HOST","item":"Hetzner App Server makepad","field":"host"}, {"environment":"canary","kind":"secret","requirement":"required","destination":"DEPLOY_SSH_PORT","item":"Hetzner App Server makepad","field":"port"}, diff --git a/docs/credential-sync.md b/docs/credential-sync.md index 6e95856..32b9831 100644 --- a/docs/credential-sync.md +++ b/docs/credential-sync.md @@ -6,6 +6,23 @@ protected GitHub environments, the four public repository policy variables, and the root/operator boundaries used by the disposable-runner control plane. Repository code never creates, rotates, or deletes a credential. +Three existing names in `staging-brio-identity-db` are separately inventoried +as name-only retained destinations: two secrets and one variable. They have no +Proton source and no sync write path: + +- `BRIO_STAGING_DB_PASSWORD` +- `BRIO_STAGING_BACKUP_DB_PASSWORD` +- `POSTGRES_HOST_COMPOSE_PROJECT` + +The two Brio application-password names remain active inputs in `canary`, but +their copies in the identity environment have no consumer: that workflow uses +only the Keycloak database roles. `POSTGRES_HOST_COMPOSE_PROJECT` is an +obsolete selector because the standalone deployment pins Compose project +`postgres` in code. The helper reports whether each exact retained name is +present, but never reads, writes, or deletes it. Keep the provider values until +an explicit, separately reviewed cleanup is authorized; any other unlisted +name remains a fail-closed error. + `deploy/github-app-contracts.json` separately pins both organization-owned App names, owner, exact selected-repository installation, disabled and empty webhooks, empty event subscriptions, and least-privilege permission maps. It @@ -81,7 +98,10 @@ the attestation key as canonical Ed25519 SubjectPublicKeyInfo PEM. It streams each value to GitHub over standard input and compares two provider read-backs byte-for-byte with the in-memory Proton value. A legacy name must be removed manually only after its consumer has migrated and the -approved replacement has been read back. Exit `0` means the reviewed names and +approved replacement has been read back. The three exact name-only retained +destinations are the bounded exception described above; they are reported but +excluded from both the managed-write set and the unexpected-name count. Exit +`0` means the reviewed names and protection are complete, exit `1` means a required source/destination or policy is incomplete, and exit `2` means an unlisted GitHub name remains. @@ -141,6 +161,14 @@ by `canary`; they must never be copied into this DB-host deployment environment. The recovery recipient certificate is needed by both workflows and is the only PKI/backup-certificate field mirrored here. +Existing identity-environment copies of `BRIO_STAGING_DB_PASSWORD` and +`BRIO_STAGING_BACKUP_DB_PASSWORD` are retained name-only: their real managed +destinations remain in `canary`, where `manual-deploy.yml` consumes them. +`POSTGRES_HOST_COMPOSE_PROJECT` is likewise retained name-only while cleanup is +pending; `deploy-brio-identity-db-host.sh` fixes the Compose project to +`postgres` and accepts no workflow-controlled selector. These entries are not +permission to recreate a missing name. + ### Release, cohort restore, and CI attestation - `release-brio-identity-db` receives only the dedicated diff --git a/scripts/sync-github-environments.sh b/scripts/sync-github-environments.sh index 67604b6..87d4c59 100755 --- a/scripts/sync-github-environments.sh +++ b/scripts/sync-github-environments.sh @@ -119,6 +119,7 @@ status_root=$(mktemp -d "${tmp_base}/postgres-credential-sync.XXXXXXXX") chmod 0700 "${status_root}" readonly status_root readonly github_entries_file=${status_root}/github-entries.tsv +readonly retained_entries_file=${status_root}/retained-environment-entries.tsv readonly repository_entries_file=${status_root}/repository-entries.tsv readonly non_github_entries_file=${status_root}/non-github-entries.tsv readonly selected_sources_file=${status_root}/selected-sources.tsv @@ -139,6 +140,10 @@ declare -a repository_destination=() declare -a repository_item=() declare -a repository_field=() declare -a repository_source_values=() +declare -a retained_environment=() +declare -a retained_kind=() +declare -a retained_destination=() +declare -a retained_classification=() repository_variable_bootstrap_token= cleanup() { @@ -159,7 +164,8 @@ trap cleanup EXIT trap 'exit 130' HUP INT TERM python3 - "${inventory}" "${repository}" "${vault}" "${selected_environment}" "${mode}" \ - "${github_entries_file}" "${repository_entries_file}" "${non_github_entries_file}" "${selected_sources_file}" <<'PY' + "${github_entries_file}" "${repository_entries_file}" "${non_github_entries_file}" "${selected_sources_file}" \ + "${retained_entries_file}" <<'PY' import json import pathlib import re @@ -174,16 +180,17 @@ github_output = pathlib.Path(sys.argv[6]) repository_output = pathlib.Path(sys.argv[7]) non_github_output = pathlib.Path(sys.argv[8]) sources_output = pathlib.Path(sys.argv[9]) +retained_output = pathlib.Path(sys.argv[10]) repository_sync = mode == "sync-repository-variables" payload = json.loads(path.read_text(encoding="utf-8")) expected_top_level = { "schemaVersion", "repository", "vault", "githubEntries", - "repositoryVariables", "nonGitHubEntries", + "repositoryVariables", "nonGitHubEntries", "retainedEnvironmentDestinations", } if set(payload) != expected_top_level: raise SystemExit("credential inventory has unexpected top-level keys") -if payload["schemaVersion"] != 1: +if payload["schemaVersion"] != 2: raise SystemExit("unsupported credential inventory schema") if payload["repository"] != expected_repository or payload["vault"] != expected_vault: raise SystemExit("credential inventory targets an unexpected repository or vault") @@ -218,12 +225,14 @@ def valid_text(value, limit=256): ) github_entries = payload["githubEntries"] +retained_entries = payload["retainedEnvironmentDestinations"] repository_entries = payload["repositoryVariables"] non_github_entries = payload["nonGitHubEntries"] -if not all(isinstance(entries, list) and entries for entries in (github_entries, repository_entries, non_github_entries)): +if not all(isinstance(entries, list) and entries for entries in (github_entries, retained_entries, repository_entries, non_github_entries)): raise SystemExit("every credential inventory section must be a non-empty list") github_lines = [] +retained_lines = [] repository_lines = [] non_github_lines = [] selected_source_requirements = {} @@ -278,6 +287,45 @@ expected_pki_destinations = { if pki_destinations != expected_pki_destinations: raise SystemExit("Brio PKI destinations do not match the reviewed workflow split") +expected_retained_entries = { + ( + "staging-brio-identity-db", "secret", + "BRIO_STAGING_BACKUP_DB_PASSWORD", "scope-duplicate-canary-consumer", + ), + ( + "staging-brio-identity-db", "secret", + "BRIO_STAGING_DB_PASSWORD", "scope-duplicate-canary-consumer", + ), + ( + "staging-brio-identity-db", "variable", + "POSTGRES_HOST_COMPOSE_PROJECT", "obsolete-fixed-in-code", + ), +} +observed_retained_entries = set() +for offset, entry in enumerate(retained_entries): + expected_keys = {"environment", "kind", "destination", "classification"} + if not isinstance(entry, dict) or set(entry) != expected_keys: + raise SystemExit(f"retained environment destination {offset} has unexpected keys") + environment = entry["environment"] + kind = entry["kind"] + destination = entry["destination"] + classification = entry["classification"] + if environment not in allowed_environments or kind not in allowed_kinds: + raise SystemExit(f"retained environment destination {offset} has an invalid classification") + if not isinstance(destination, str) or not destination_pattern.fullmatch(destination): + raise SystemExit(f"retained environment destination {offset} has an invalid destination") + if not valid_text(classification): + raise SystemExit(f"retained environment destination {offset} has an invalid classification") + identity = (environment, kind, destination) + if identity in seen_github: + raise SystemExit(f"retained destination overlaps a managed GitHub destination: {environment}/{kind}/{destination}") + seen_github.add(identity) + observed_retained_entries.add((*identity, classification)) + if not selected_environment or environment == selected_environment: + retained_lines.append("\t".join((*identity, classification))) +if observed_retained_entries != expected_retained_entries: + raise SystemExit("retained environment destination matrix does not match the reviewed PostgreSQL contract") + for offset, entry in enumerate(repository_entries): expected_keys = {"requirement", "destination", "item", "field"} if not isinstance(entry, dict) or set(entry) != expected_keys: @@ -328,6 +376,7 @@ for offset, entry in enumerate(non_github_entries): selected_source_requirements[item] = "required" if requirement == "required" or prior == "required" else "optional" github_output.write_text("\n".join(github_lines) + "\n", encoding="utf-8") +retained_output.write_text("\n".join(retained_lines) + ("\n" if retained_lines else ""), encoding="utf-8") repository_output.write_text("\n".join(repository_lines) + "\n", encoding="utf-8") non_github_output.write_text("\n".join(non_github_lines) + "\n", encoding="utf-8") sources_output.write_text( @@ -348,6 +397,14 @@ while IFS=$'\t' read -r environment kind requirement destination item field; do entry_field[index]=${field} done <"${github_entries_file}" +while IFS=$'\t' read -r environment kind destination classification; do + index=${#retained_environment[@]} + retained_environment[index]=${environment} + retained_kind[index]=${kind} + retained_destination[index]=${destination} + retained_classification[index]=${classification} +done <"${retained_entries_file}" + while IFS=$'\t' read -r requirement destination item field; do index=${#repository_destination[@]} repository_requirement[index]=${requirement} @@ -392,8 +449,12 @@ repository_variable_gh() { destination_expected() { local environment=$1 kind=$2 destination=$3 + if awk -F '\t' -v environment="${environment}" -v kind="${kind}" -v destination="${destination}" \ + '$1 == environment && $2 == kind && $4 == destination { found = 1 } END { exit !found }' "${github_entries_file}"; then + return 0 + fi awk -F '\t' -v environment="${environment}" -v kind="${kind}" -v destination="${destination}" \ - '$1 == environment && $2 == kind && $4 == destination { found = 1 } END { exit !found }' "${github_entries_file}" + '$1 == environment && $2 == kind && $3 == destination { found = 1 } END { exit !found }' "${retained_entries_file}" } repository_variable_expected() { @@ -453,7 +514,7 @@ load_names_and_policy() { } report_status() { - local environment kind requirement destination item item_count destination_file status actual_name boundary field + local environment kind requirement destination item item_count destination_file status actual_name boundary field classification missing_required_sources=0 missing_required_destinations=0 missing_required_repository_variables=0 @@ -500,6 +561,23 @@ report_status() { done fi + if environment_destinations_in_scope; then + for index in "${!retained_environment[@]}"; do + environment=${retained_environment[index]} + kind=${retained_kind[index]} + destination=${retained_destination[index]} + classification=${retained_classification[index]} + destination_file=${status_root}/github-environment-${environment}-${kind}.txt + if [[ -f "${destination_file}" ]] && grep -Fqx -- "${destination}" "${destination_file}"; then + status=name-only-present + else + status=absent + fi + printf 'PRESERVED_DESTINATION environment=%s kind=%s name=%s status=%s classification=%s\n' \ + "${environment}" "${kind}" "${destination}" "${status}" "${classification}" + done + fi + if environment_destinations_in_scope; then for environment in ${allowed_environments}; do environment_selected "${environment}" || continue diff --git a/scripts/test-sync-github-environments.sh b/scripts/test-sync-github-environments.sh index 596fd4d..32fdef7 100755 --- a/scripts/test-sync-github-environments.sh +++ b/scripts/test-sync-github-environments.sh @@ -271,6 +271,7 @@ assert_no_value_read_or_write() { # The machine-readable inventory encodes the exact workflow-backed PKI split. jq -e ' + .schemaVersion == 2 and ([.githubEntries[] | select((.environment == "canary" or .environment == "production") and (.destination | startswith("DEPLOY_SSH_"))) | .item] | unique) == ["Hetzner App Server makepad"] and ([.githubEntries[] | select((.environment == "staging-brio-identity-db" or .environment == "keycloak-cohort-restore") and (.destination | test("SSH_(HOST|PORT|USER|PRIVATE_KEY|KNOWN_HOSTS)$"))) | .item] | unique) == ["Hetzner Database Server makepad"] and ([.githubEntries[] | select(.item == "Hetzner App Server makepad" or .item == "Hetzner Database Server makepad") | {destination, field}] | unique | sort_by(.destination)) == ([ @@ -311,6 +312,11 @@ jq -e ' "POSTGRES_CI_LAUNCHER_APP_SENDER_ID", "POSTGRES_PR_CHECK_APP_ID" ] | sort) and + ([.retainedEnvironmentDestinations[] | [.environment, .kind, .destination, .classification]] | sort) == ([ + ["staging-brio-identity-db", "secret", "BRIO_STAGING_BACKUP_DB_PASSWORD", "scope-duplicate-canary-consumer"], + ["staging-brio-identity-db", "secret", "BRIO_STAGING_DB_PASSWORD", "scope-duplicate-canary-consumer"], + ["staging-brio-identity-db", "variable", "POSTGRES_HOST_COMPOSE_PROJECT", "obsolete-fixed-in-code"] + ] | sort) and ([.githubEntries[] | select(.destination | test("PASSWORD|TOKEN|PRIVATE_KEY|SERVER_KEY")) | .kind] | all(. == "secret")) and ([.nonGitHubEntries[] | select(.destination | test("controller.env|private-key|HOST_ALERT"))] | length >= 5) ' "${inventory}" >/dev/null @@ -387,6 +393,27 @@ run_helper 2 env FAKE_UNEXPECTED_DESTINATION=LEGACY_KEYCLOAK_PASSWORD \ grep -Fq 'scope=environment environment=canary kind=secret name=LEGACY_KEYCLOAK_PASSWORD status=legacy-or-unmanaged' <<<"${output}" assert_no_value_read_or_write +# These exact names already exist in the identity environment but are not +# managed credential routes. Preserve them name-only pending a separately +# authorized provider cleanup; never read a Proton field or write them. +while IFS=' ' read -r preserved_kind preserved_destination; do + : >"${audit_log}" + run_helper 0 env \ + FAKE_UNEXPECTED_DESTINATION="${preserved_destination}" \ + FAKE_UNEXPECTED_ENVIRONMENT=staging-brio-identity-db \ + FAKE_UNEXPECTED_KIND="${preserved_kind}" \ + "${helper}" --sync --environment staging-brio-identity-db + grep -Fq "PRESERVED_DESTINATION environment=staging-brio-identity-db kind=${preserved_kind} name=${preserved_destination} status=name-only-present" <<<"${output}" + if grep -Fq "gh-set environment=staging-brio-identity-db kind=${preserved_kind} name=${preserved_destination} " "${audit_log}"; then + echo 'a name-only retained destination was written' >&2 + exit 1 + fi +done <<'PRESERVED_DESTINATIONS' +secret BRIO_STAGING_BACKUP_DB_PASSWORD +secret BRIO_STAGING_DB_PASSWORD +variable POSTGRES_HOST_COMPOSE_PROJECT +PRESERVED_DESTINATIONS + : >"${audit_log}" run_helper 2 env FAKE_REPOSITORY_LEGACY=LEGACY_REPOSITORY_TOKEN FAKE_REPOSITORY_LEGACY_KIND=secret \ "${helper}" --check --environment canary @@ -612,4 +639,11 @@ run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check grep -Fq 'non-GitHub boundary/destination/item/field matrix differs from review' <<<"${output}" [[ ! -s "${audit_log}" ]] +jq '(.retainedEnvironmentDestinations[0].classification) = "adversarial-classification"' \ + "${inventory}" >"${candidate_root}/deploy/credential-inventory.json" +: >"${audit_log}" +run_helper 1 "${candidate_root}/scripts/sync-github-environments.sh" --check --environment staging-brio-identity-db +grep -Fq 'retained environment destination matrix differs from review' <<<"${output}" +[[ ! -s "${audit_log}" ]] + printf '%s\n' 'PostgreSQL Proton-to-GitHub credential sync tests passed.' diff --git a/scripts/validate-credential-inventory-contract.py b/scripts/validate-credential-inventory-contract.py index a3d7535..acc6605 100755 --- a/scripts/validate-credential-inventory-contract.py +++ b/scripts/validate-credential-inventory-contract.py @@ -168,6 +168,27 @@ def github(item: str, **destinations: str) -> set[tuple[str, str, str, str]]: ), } +EXPECTED_RETAINED_ENVIRONMENT_DESTINATIONS = { + ( + "staging-brio-identity-db", + "secret", + "BRIO_STAGING_BACKUP_DB_PASSWORD", + "scope-duplicate-canary-consumer", + ), + ( + "staging-brio-identity-db", + "secret", + "BRIO_STAGING_DB_PASSWORD", + "scope-duplicate-canary-consumer", + ), + ( + "staging-brio-identity-db", + "variable", + "POSTGRES_HOST_COMPOSE_PROJECT", + "obsolete-fixed-in-code", + ), +} + EXPECTED_NON_GITHUB_ENTRIES = { ("operator-verification", "PostgreSQL Checks App private-key fingerprint", "PostgreSQL · PR Checks App", "private_key_fingerprint"), ("host-root-setting", "/etc/makepad/postgres-ci/controller.env:POSTGRES_CI_LAUNCHER_APP_ID", "PostgreSQL · JIT Launcher App", "app_id"), @@ -208,12 +229,13 @@ def validate_inventory(payload: Any) -> None: "schemaVersion", "repository", "vault", + "retainedEnvironmentDestinations", "githubEntries", "repositoryVariables", "nonGitHubEntries", }: fail("top-level shape differs from the reviewed contract") - if payload["schemaVersion"] != 1 or payload["repository"] != REPOSITORY or payload["vault"] != VAULT: + if payload["schemaVersion"] != 2 or payload["repository"] != REPOSITORY or payload["vault"] != VAULT: fail("schema, repository, or vault identity differs from the reviewed contract") github_entries = exact_entries( @@ -232,6 +254,31 @@ def validate_inventory(payload: Any) -> None: if actual_by_environment != EXPECTED_GITHUB_ENTRIES: fail("per-environment kind/destination/item/field matrix differs from review") + retained_entries = payload["retainedEnvironmentDestinations"] + if not isinstance(retained_entries, list) or not retained_entries: + fail("retained environment destinations must be a non-empty list") + actual_retained: set[tuple[str, str, str, str]] = set() + managed_destinations = { + (entry["environment"], entry["kind"], entry["destination"]) + for entry in github_entries + } + for offset, entry in enumerate(retained_entries): + if not isinstance(entry, dict) or set(entry) != { + "environment", + "kind", + "destination", + "classification", + }: + fail(f"retained environment destination entry {offset} has unexpected keys") + if not all(isinstance(value, str) and value for value in entry.values()): + fail(f"retained environment destination entry {offset} contains invalid text") + identity = (entry["environment"], entry["kind"], entry["destination"]) + if identity in managed_destinations: + fail(f"retained environment destination entry {offset} overlaps a managed destination") + actual_retained.add((*identity, entry["classification"])) + if len(actual_retained) != len(retained_entries) or actual_retained != EXPECTED_RETAINED_ENVIRONMENT_DESTINATIONS: + fail("retained environment destination matrix differs from review") + repository_entries = exact_entries( payload["repositoryVariables"], {"requirement", "destination", "item", "field"}, diff --git a/scripts/validate-postgres-config.sh b/scripts/validate-postgres-config.sh index 22350e8..7baa910 100755 --- a/scripts/validate-postgres-config.sh +++ b/scripts/validate-postgres-config.sh @@ -723,18 +723,19 @@ credential_inventory = json.loads(read_required_text(credential_inventory_path, require( set(credential_inventory) == { "schemaVersion", "repository", "vault", "githubEntries", - "repositoryVariables", "nonGitHubEntries", + "repositoryVariables", "nonGitHubEntries", "retainedEnvironmentDestinations", }, "Credential inventory has unexpected top-level keys.", ) -require(credential_inventory["schemaVersion"] == 1, "Credential inventory schema must be version 1.") +require(credential_inventory["schemaVersion"] == 2, "Credential inventory schema must be version 2.") require(credential_inventory["repository"] == "Makepad-fr/postgres", "Credential inventory targets the wrong repository.") require(credential_inventory["vault"] == "Makepad", "Credential inventory targets the wrong Proton vault.") github_entries = credential_inventory["githubEntries"] +retained_destinations = credential_inventory["retainedEnvironmentDestinations"] repository_variables = credential_inventory["repositoryVariables"] non_github_entries = credential_inventory["nonGitHubEntries"] -require(all(isinstance(entries, list) and entries for entries in (github_entries, repository_variables, non_github_entries)), "Every credential inventory section must be non-empty.") +require(all(isinstance(entries, list) and entries for entries in (github_entries, retained_destinations, repository_variables, non_github_entries)), "Every credential inventory section must be non-empty.") required_environments = { "canary", "production", "staging-brio-identity-db", "release-brio-identity-db", "keycloak-cohort-restore", @@ -746,6 +747,38 @@ github_destinations = { for entry in github_entries } require(len(github_destinations) == len(github_entries), "Credential inventory has duplicate environment destinations.") +retained_destination_tuples = { + ( + entry.get("environment"), entry.get("kind"), + entry.get("destination"), entry.get("classification"), + ) + for entry in retained_destinations +} +require( + retained_destination_tuples == { + ( + "staging-brio-identity-db", "secret", + "BRIO_STAGING_BACKUP_DB_PASSWORD", "scope-duplicate-canary-consumer", + ), + ( + "staging-brio-identity-db", "secret", + "BRIO_STAGING_DB_PASSWORD", "scope-duplicate-canary-consumer", + ), + ( + "staging-brio-identity-db", "variable", + "POSTGRES_HOST_COMPOSE_PROJECT", "obsolete-fixed-in-code", + ), + }, + "Name-only retained staging destinations drifted.", +) +require( + len(retained_destination_tuples) == len(retained_destinations), + "Credential inventory has duplicate retained environment destinations.", +) +require( + not ({entry[:3] for entry in retained_destination_tuples} & github_destinations), + "A name-only retained destination overlaps a managed credential route.", +) repository_destinations = {entry.get("destination") for entry in repository_variables} require(len(repository_destinations) == len(repository_variables), "Credential inventory has duplicate repository variables.") require( @@ -850,6 +883,7 @@ for required in ( "inventory_contract_validator", "provider_contract_validator", "repository_variable_bootstrap_token", + "PRESERVED_DESTINATION environment=%s kind=%s name=%s", ): require(required in credential_sync, f"Credential sync helper is missing fail-closed control: {required}") for forbidden in ("gh secret delete", "gh variable delete", "pass-cli item delete"): @@ -877,12 +911,14 @@ for required in ( "repository_last_source_read < repository_first_write", "per-environment kind/destination/item/field matrix differs from review", "FAKE_ADVERSARIAL_ENVIRONMENT", + "POSTGRES_HOST_COMPOSE_PROJECT", ): require(required in credential_sync_test, f"Credential sync behavioral test is missing adversarial case: {required}") for required in ( "EXPECTED_GITHUB_ENTRIES", "EXPECTED_REPOSITORY_VARIABLES", "EXPECTED_NON_GITHUB_ENTRIES", + "EXPECTED_RETAINED_ENVIRONMENT_DESTINATIONS", "per-environment kind/destination/item/field matrix differs from review", "repository-variable destination/item/field matrix differs from review", "non-GitHub boundary/destination/item/field matrix differs from review", @@ -1128,7 +1164,11 @@ for required in ("makepad.cleanup.contract", "makepad.cleanup.expires-epoch", "d require("install-keycloak-cohort-cleaner.sh" in cohort_host_installer and "makepad-keycloak-cohort-cleaner.timer" in cohort_cleaner_installer, "Capture host must install the persistent cohort resource cleaner.") require("makepad.keycloak-config-fingerprint.v2" in cohort_evidence_validator, "Cohort evidence must bind the v2 fingerprint schema.") require("test-keycloak-cohort-hardening.sh" in ci_runner, "CI must run the cohort hardening contract test.") -require("POSTGRES_HOST_COMPOSE_PROJECT" not in identity_workflow + readme, "The standalone Compose project must be fixed in code, not selected by a workflow variable.") +require("POSTGRES_HOST_COMPOSE_PROJECT" not in identity_workflow, "The standalone Compose project must be fixed in code, not selected by a workflow variable.") +require( + "POSTGRES_HOST_COMPOSE_PROJECT" in readme and "pins project `postgres` in code" in normalized_readme, + "README must classify the obsolete Compose selector without restoring it as an input.", +) for required in ( "Swarm.LocalNodeState",