From b5071f14943a5a7207b1236e111c4dfb70d983c4 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 4 Aug 2026 15:04:39 +0200 Subject: [PATCH] deploy: isolate git and connector secrets --- deploy/compose.connector-secrets.yaml | 15 +++ deploy/compose.git-https.yaml | 13 ++ deploy/compose.git-ssh.yaml | 9 ++ deploy/workspace-registry.env.example | 10 +- scripts/test-compose-secret-policy.sh | 127 ++++++++++++++++++ scripts/test-verify-workspace-install-docs.sh | 4 +- scripts/verify-workspace-install-docs.sh | 14 ++ 7 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 deploy/compose.connector-secrets.yaml create mode 100644 deploy/compose.git-https.yaml create mode 100644 deploy/compose.git-ssh.yaml create mode 100755 scripts/test-compose-secret-policy.sh diff --git a/deploy/compose.connector-secrets.yaml b/deploy/compose.connector-secrets.yaml new file mode 100644 index 00000000..fb318699 --- /dev/null +++ b/deploy/compose.connector-secrets.yaml @@ -0,0 +1,15 @@ +# Selected direct PostgreSQL/pgvector connector secrets. Each target must match a corresponding +# THT_WS_*_FILE=/run/secrets/ binding; source paths are host-only operator configuration. +services: + core: + secrets: + - source: psd_clinical_dwh_password + target: psd-clinical-dwh-password + - source: psd_clinical_vector_password + target: psd-clinical-vector-password + +secrets: + psd_clinical_dwh_password: + file: ${THT_WS_PSD_CLINICAL_DWH_PASSWORD_SOURCE:?set THT_WS_PSD_CLINICAL_DWH_PASSWORD_SOURCE} + psd_clinical_vector_password: + file: ${THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_SOURCE:?set THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_SOURCE} diff --git a/deploy/compose.git-https.yaml b/deploy/compose.git-https.yaml new file mode 100644 index 00000000..d1373c44 --- /dev/null +++ b/deploy/compose.git-https.yaml @@ -0,0 +1,13 @@ +# Select this override only for an HTTPS Git remote. The separate CA mount keeps TLS validation +# explicit; neither host-only source file nor its contents belongs in the base Compose contract. +services: + core: + environment: + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_KEY_0: credential.helper + GIT_CONFIG_VALUE_0: store --file=/run/secrets/workspace-registry-git-credentials + GIT_CONFIG_KEY_1: http.sslCAInfo + GIT_CONFIG_VALUE_1: /run/secrets/workspace-registry-git-ca + volumes: + - ${THT_WORKSPACE_GIT_CREDENTIALS_FILE:?set THT_WORKSPACE_GIT_CREDENTIALS_FILE}:/run/secrets/workspace-registry-git-credentials:ro + - ${THT_WORKSPACE_GIT_CA_FILE:?set THT_WORKSPACE_GIT_CA_FILE}:/run/secrets/workspace-registry-git-ca:ro diff --git a/deploy/compose.git-ssh.yaml b/deploy/compose.git-ssh.yaml new file mode 100644 index 00000000..7ba19a8a --- /dev/null +++ b/deploy/compose.git-ssh.yaml @@ -0,0 +1,9 @@ +# Select this override only for an SSH Git remote. The host-only source files must be absolute, +# normalized paths; strict host-key checking is mandatory for registry pull and publish. +services: + core: + environment: + GIT_SSH_COMMAND: ssh -i /run/secrets/workspace-registry-git-ssh-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/run/secrets/workspace-registry-git-known-hosts + volumes: + - ${THT_WORKSPACE_GIT_SSH_KEY_FILE:?set THT_WORKSPACE_GIT_SSH_KEY_FILE}:/run/secrets/workspace-registry-git-ssh-key:ro + - ${THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE:?set THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE}:/run/secrets/workspace-registry-git-known-hosts:ro diff --git a/deploy/workspace-registry.env.example b/deploy/workspace-registry.env.example index 0625176d..e1a6c0e8 100644 --- a/deploy/workspace-registry.env.example +++ b/deploy/workspace-registry.env.example @@ -1,6 +1,7 @@ # Copy these non-secret registry settings into the installation environment. -# Create the referenced credential, CA, SSH-key, and known-hosts files locally with restrictive -# permissions. Their contents are never committed, emitted by the API, or stored in the registry. +# Select at most one Git transport override and only the connector-secret entries whose matching +# THT_WS_*_FILE bindings are declared. Every host path below must be absolute and normalized. +# Their contents are never committed, emitted by the API, or stored in the registry. THT_WORKSPACE_REGISTRY_ROOT=/data/workspace-registry THT_WORKSPACE_GIT_BRANCH=main THT_WORKSPACE_INSTALLATION_ID=local @@ -13,3 +14,8 @@ THT_WORKSPACE_GIT_AUTHOR_EMAIL=thoth-workspace-registry@localhost # THT_WORKSPACE_GIT_CA_FILE=/absolute/path/to/git-ca.pem # THT_WORKSPACE_GIT_SSH_KEY_FILE=/absolute/path/to/git-ssh-key # THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=/absolute/path/to/git-known-hosts + +# Host-only connector sources consumed only by deploy/compose.connector-secrets.yaml. The matching +# THT_WS_*_FILE values belong in the separate workspace bindings env file and target /run/secrets. +# THT_WS_PSD_CLINICAL_DWH_PASSWORD_SOURCE=/absolute/path/to/psd-clinical-dwh-password +# THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_SOURCE=/absolute/path/to/psd-clinical-vector-password diff --git a/scripts/test-compose-secret-policy.sh b/scripts/test-compose-secret-policy.sh new file mode 100755 index 00000000..cd2e7927 --- /dev/null +++ b/scripts/test-compose-secret-policy.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Render optional secret overrides with disposable sources and enforce their isolation contract. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd -P)" +fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/thoth-compose-secret-policy.XXXXXX")" +trap 'rm -rf "$fixture_root"' EXIT HUP INT TERM + +write_secret() { + local path="$1" value="$2" + printf '%s' "$value" >"$path" + chmod 600 "$path" +} + +render() { + local name="$1"; shift + docker compose --env-file "$fixture_root/.env" -f "$root/compose.yaml" "$@" config --format json \ + >"$fixture_root/$name.json" +} + +assert_render_contract() { + local name="$1" expected_targets="$2" + node - "$fixture_root/$name.json" "$name" "$expected_targets" \ + "$fixture_root/ssh-private-key" "$fixture_root/ssh-known-hosts" \ + "$fixture_root/https-credentials" "$fixture_root/https-ca.pem" \ + "$fixture_root/dwh-password" "$fixture_root/vector-password" <<'NODE' +const fs = require("fs"); + +const [configPath, name, expectedTargets, ...sourcePaths] = process.argv.slice(2); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +const core = config.services?.core; +if (!core) throw new Error(`${name}: missing core service`); +if (name === "base" && (core.volumes || []).some((mount) => mount.source === "/dev/null")) { + throw new Error("base: /dev/null must never be used as a secret mount source"); +} + +const mountTargets = (core.volumes || []) + .filter((mount) => mount.target?.startsWith("/run/secrets/")) + .map((mount) => mount.target) + .sort(); +const expectedMountTargets = expectedTargets ? expectedTargets.split(",").filter(Boolean).sort() : []; +if (mountTargets.join(",") !== expectedMountTargets.join(",")) { + throw new Error(`${name}: unexpected /run/secrets bind targets: ${mountTargets.join(",")}`); +} +for (const mount of (core.volumes || []).filter((item) => item.target?.startsWith("/run/secrets/"))) { + if (mount.type !== "bind" || !mount.read_only) { + throw new Error(`${name}: secret bind ${mount.target} must be read-only`); + } +} + +const secretTargets = (core.secrets || []).map((secret) => secret.target).sort(); +if (name === "connector" && secretTargets.join(",") !== "psd-clinical-dwh-password,psd-clinical-vector-password") { + throw new Error(`${name}: connector secret targets do not match declared THT_WS_*_FILE paths`); +} +if (name !== "connector" && secretTargets.length !== 0) { + throw new Error(`${name}: unexpected Docker secrets`); +} + +if (name === "ssh") { + const command = core.environment?.GIT_SSH_COMMAND || ""; + for (const option of ["IdentitiesOnly=yes", "StrictHostKeyChecking=yes", "UserKnownHostsFile=/run/secrets/workspace-registry-git-known-hosts"]) { + if (!command.includes(option)) throw new Error(`ssh: missing strict SSH option ${option}`); + } +} +if (name === "https") { + if (core.environment?.GIT_CONFIG_VALUE_1 !== "/run/secrets/workspace-registry-git-ca") { + throw new Error("https: HTTPS CA verification is not configured"); + } +} + +const rendered = JSON.stringify(config); +for (const secret of ["fixture-ssh-private-key", "fixture-ssh-known-hosts", "fixture-https-credentials", "fixture-https-ca", "fixture-dwh-password", "fixture-vector-password"]) { + if (rendered.includes(secret)) throw new Error(`${name}: rendered Compose leaked fixture secret value`); +} +for (const sourcePath of sourcePaths) { + if (!sourcePath.startsWith("/")) throw new Error(`${name}: fixture source must be absolute`); +} +NODE +} + +assert_required_source() { + local variable="$1" override="$2" + local missing_env="$fixture_root/missing-$variable.env" + grep -v "^$variable=" "$fixture_root/.env" >"$missing_env" + if env -u "$variable" docker compose --env-file "$missing_env" -f "$root/compose.yaml" -f "$override" config --quiet \ + >"$fixture_root/missing-$variable.out" 2>"$fixture_root/missing-$variable.err"; then + echo "selected override accepted missing $variable" >&2 + exit 1 + fi + grep -Fq "$variable" "$fixture_root/missing-$variable.err" +} + +write_secret "$fixture_root/pi-auth.json" 'fixture-pi-auth' +write_secret "$fixture_root/ssh-private-key" 'fixture-ssh-private-key' +write_secret "$fixture_root/ssh-known-hosts" 'fixture-ssh-known-hosts' +write_secret "$fixture_root/https-credentials" 'fixture-https-credentials' +write_secret "$fixture_root/https-ca.pem" 'fixture-https-ca' +write_secret "$fixture_root/dwh-password" 'fixture-dwh-password' +write_secret "$fixture_root/vector-password" 'fixture-vector-password' + +printf '%s\n' \ + 'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \ + "PI_AUTH_FILE=$fixture_root/pi-auth.json" \ + "THT_WORKSPACE_GIT_SSH_KEY_FILE=$fixture_root/ssh-private-key" \ + "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=$fixture_root/ssh-known-hosts" \ + "THT_WORKSPACE_GIT_CREDENTIALS_FILE=$fixture_root/https-credentials" \ + "THT_WORKSPACE_GIT_CA_FILE=$fixture_root/https-ca.pem" \ + "THT_WS_PSD_CLINICAL_DWH_PASSWORD_SOURCE=$fixture_root/dwh-password" \ + "THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_SOURCE=$fixture_root/vector-password" >"$fixture_root/.env" + +render base +assert_render_contract base '' +render ssh -f "$root/deploy/compose.git-ssh.yaml" +assert_render_contract ssh '/run/secrets/workspace-registry-git-known-hosts,/run/secrets/workspace-registry-git-ssh-key' +render https -f "$root/deploy/compose.git-https.yaml" +assert_render_contract https '/run/secrets/workspace-registry-git-ca,/run/secrets/workspace-registry-git-credentials' +render connector -f "$root/deploy/compose.connector-secrets.yaml" +assert_render_contract connector '' + +assert_required_source THT_WORKSPACE_GIT_SSH_KEY_FILE "$root/deploy/compose.git-ssh.yaml" +assert_required_source THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE "$root/deploy/compose.git-ssh.yaml" +assert_required_source THT_WORKSPACE_GIT_CREDENTIALS_FILE "$root/deploy/compose.git-https.yaml" +assert_required_source THT_WORKSPACE_GIT_CA_FILE "$root/deploy/compose.git-https.yaml" +assert_required_source THT_WS_PSD_CLINICAL_DWH_PASSWORD_SOURCE "$root/deploy/compose.connector-secrets.yaml" +assert_required_source THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_SOURCE "$root/deploy/compose.connector-secrets.yaml" + +echo "Compose secret policy passed." diff --git a/scripts/test-verify-workspace-install-docs.sh b/scripts/test-verify-workspace-install-docs.sh index d0506271..57ee6f51 100755 --- a/scripts/test-verify-workspace-install-docs.sh +++ b/scripts/test-verify-workspace-install-docs.sh @@ -16,7 +16,9 @@ for fixture in \ "copied connector binding/secret fixture" \ "core process sees connector bindings and secret files" \ "non-path secret-file fixture rejected" \ - "literal secret-source fixture rejected"; do + "literal secret-source fixture rejected" \ + "relative secret-source fixture rejected" \ + "non-normalized secret-source fixture rejected"; do grep -Fqx "$fixture passed" "$output" >/dev/null || { echo "missing fixture verification: $fixture" >&2 cat "$output" >&2 diff --git a/scripts/verify-workspace-install-docs.sh b/scripts/verify-workspace-install-docs.sh index 5d67620e..0c6d31b0 100755 --- a/scripts/verify-workspace-install-docs.sh +++ b/scripts/verify-workspace-install-docs.sh @@ -190,6 +190,20 @@ verify_copied_operator_fixtures() { return 1 fi echo "literal secret-source fixture rejected passed" + + printf 'THT_WS_EXAMPLE_DWH_PASSWORD_SOURCE=installation-secrets/password\n' >"$fixture_root/relative-source.env" + if verify_path_variable_values "$fixture_root/relative-source.env" >/dev/null 2>&1; then + echo "relative secret-source fixture was accepted" >&2 + return 1 + fi + echo "relative secret-source fixture rejected passed" + + printf 'THT_WS_EXAMPLE_DWH_PASSWORD_SOURCE=/srv/thothii/secrets/../password\n' >"$fixture_root/non-normalized-source.env" + if verify_path_variable_values "$fixture_root/non-normalized-source.env" >/dev/null 2>&1; then + echo "non-normalized secret-source fixture was accepted" >&2 + return 1 + fi + echo "non-normalized secret-source fixture rejected passed" } case "$profile" in