Harden Observatory private staging deploy gate (#163)
Some checks are pending
CI / lint-and-test (push) Waiting to run
Some checks are pending
CI / lint-and-test (push) Waiting to run
Merge the T2 readiness and least-privilege staging gate. This does not deploy Cloud Run, mutate Cloud SQL, or change production routing.
This commit is contained in:
parent
33399bd9ac
commit
36fe0d3cc2
10 changed files with 1966 additions and 84 deletions
174
.github/workflows/gcp-observatory-read-adapter.yml
vendored
174
.github/workflows/gcp-observatory-read-adapter.yml
vendored
|
|
@ -4,12 +4,13 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
description: Build only, or deploy the protected staging service and run live receipts
|
description: Build, preflight, or deploy the protected staging service and run live receipts
|
||||||
required: true
|
required: true
|
||||||
default: build_only
|
default: build_only
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- build_only
|
- build_only
|
||||||
|
- preflight_staging
|
||||||
- deploy_staging
|
- deploy_staging
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -28,10 +29,13 @@ env:
|
||||||
IMAGE_NAME: observatory-read-adapter
|
IMAGE_NAME: observatory-read-adapter
|
||||||
SERVICE_NAME: observatory-read-adapter-staging
|
SERVICE_NAME: observatory-read-adapter-staging
|
||||||
RUNTIME_SERVICE_ACCOUNT: sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com
|
RUNTIME_SERVICE_ACCOUNT: sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com
|
||||||
|
BUILD_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com
|
||||||
|
DEPLOY_SERVICE_ACCOUNT: sa-observatory-deployer@teleo-501523.iam.gserviceaccount.com
|
||||||
DB_IAM_USER: sa-observatory-read-adapter@teleo-501523.iam
|
DB_IAM_USER: sa-observatory-read-adapter@teleo-501523.iam
|
||||||
API_KEY_SECRET: observatory-read-api-key-staging
|
API_KEY_SECRET: observatory-read-api-key-staging
|
||||||
WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
BUILD_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||||
DEPLOY_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com
|
DEPLOY_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/observatory-read-adapter-main
|
||||||
|
EXPECTED_WORKFLOW_REF: living-ip/teleo-infrastructure/.github/workflows/gcp-observatory-read-adapter.yml@refs/heads/main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -57,8 +61,8 @@ jobs:
|
||||||
- id: auth
|
- id: auth
|
||||||
uses: google-github-actions/auth@v3
|
uses: google-github-actions/auth@v3
|
||||||
with:
|
with:
|
||||||
workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
|
workload_identity_provider: ${{ env.BUILD_WORKLOAD_IDENTITY_PROVIDER }}
|
||||||
service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }}
|
service_account: ${{ env.BUILD_SERVICE_ACCOUNT }}
|
||||||
|
|
||||||
- uses: google-github-actions/setup-gcloud@v3
|
- uses: google-github-actions/setup-gcloud@v3
|
||||||
with:
|
with:
|
||||||
|
|
@ -68,28 +72,21 @@ jobs:
|
||||||
run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
|
run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
|
||||||
|
|
||||||
- id: image
|
- id: image
|
||||||
name: Build, smoke, and push immutable image
|
name: Build, smoke, and push run-unique immutable image
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
tag="${GITHUB_SHA::12}"
|
tag="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
image="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${tag}"
|
image="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${tag}"
|
||||||
digest="$(gcloud artifacts docker images describe "${image}" \
|
|
||||||
--project="${PROJECT_ID}" \
|
|
||||||
--format='value(image_summary.digest)' 2>/dev/null || true)"
|
|
||||||
if [[ -n "${digest}" ]]; then
|
|
||||||
docker pull "${image}"
|
|
||||||
else
|
|
||||||
docker build -f Dockerfile.observatory-read-adapter -t "${image}" .
|
docker build -f Dockerfile.observatory-read-adapter -t "${image}" .
|
||||||
fi
|
|
||||||
docker run --rm --env PYTHONPYCACHEPREFIX=/tmp/pycache \
|
docker run --rm --env PYTHONPYCACHEPREFIX=/tmp/pycache \
|
||||||
"${image}" python -m compileall -q /app/observatory_read_adapter
|
"${image}" python -m compileall -q /app/observatory_read_adapter
|
||||||
docker run --rm "${image}" python -c 'import observatory_read_adapter; print("adapter-import-ok")'
|
docker run --rm "${image}" python -c 'import observatory_read_adapter; print("adapter-import-ok")'
|
||||||
if [[ -z "${digest}" ]]; then
|
docker push "${image}"
|
||||||
docker push "${image}" | tee docker-push.log
|
digest="$(gcloud artifacts docker images describe "${image}" \
|
||||||
digest="$(awk '/digest: sha256:/ {print $3}' docker-push.log | tail -1)"
|
--project="${PROJECT_ID}" \
|
||||||
fi
|
--format='value(image_summary.digest)')"
|
||||||
test -n "${digest}"
|
[[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||||
image_ref="${image}@${digest}"
|
image_ref="${image}@${digest}"
|
||||||
printf 'image_ref=%s\n' "${image_ref}" >> "${GITHUB_OUTPUT}"
|
printf 'image_ref=%s\n' "${image_ref}" >> "${GITHUB_OUTPUT}"
|
||||||
printf 'revision=%s\nimage_ref=%s\n' "${GITHUB_SHA}" "${image_ref}" > observatory-adapter-image.txt
|
printf 'revision=%s\nimage_ref=%s\n' "${GITHUB_SHA}" "${image_ref}" > observatory-adapter-image.txt
|
||||||
|
|
@ -100,31 +97,94 @@ jobs:
|
||||||
path: observatory-adapter-image.txt
|
path: observatory-adapter-image.txt
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
deploy-staging:
|
preflight-staging:
|
||||||
name: Deploy and prove staging read path
|
name: Verify private staging prerequisites
|
||||||
if: ${{ inputs.action == 'deploy_staging' }}
|
if: ${{ inputs.action != 'build_only' }}
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enforce reviewed main-only staging path
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "${GITHUB_REF}" == "refs/heads/main" ]]
|
||||||
|
[[ "${GITHUB_WORKFLOW_REF}" == "${EXPECTED_WORKFLOW_REF}" ]]
|
||||||
|
[[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]
|
||||||
|
|
||||||
- id: auth
|
- id: auth
|
||||||
uses: google-github-actions/auth@v3
|
uses: google-github-actions/auth@v3
|
||||||
with:
|
with:
|
||||||
workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
|
workload_identity_provider: ${{ env.DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
|
||||||
service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }}
|
service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }}
|
||||||
|
|
||||||
- uses: google-github-actions/setup-gcloud@v3
|
- uses: google-github-actions/setup-gcloud@v3
|
||||||
with:
|
with:
|
||||||
project_id: ${{ env.PROJECT_ID }}
|
project_id: ${{ env.PROJECT_ID }}
|
||||||
|
|
||||||
- name: Deploy bounded GCP staging service
|
- name: Run adapter-specific read-only GCP preflight
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
python3 ops/check_observatory_read_adapter_gcp_preflight.py \
|
||||||
|
--image-ref "${IMAGE_REF}" \
|
||||||
|
--output observatory-read-adapter-preflight.json
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: observatory-read-adapter-preflight
|
||||||
|
path: observatory-read-adapter-preflight.json
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
deploy-staging:
|
||||||
|
name: Deploy and prove staging read path
|
||||||
|
if: ${{ inputs.action == 'deploy_staging' }}
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
- preflight-staging
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enforce reviewed main-only staging path
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "${GITHUB_REF}" == "refs/heads/main" ]]
|
||||||
|
[[ "${GITHUB_WORKFLOW_REF}" == "${EXPECTED_WORKFLOW_REF}" ]]
|
||||||
|
[[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]
|
||||||
|
|
||||||
|
- id: auth
|
||||||
|
uses: google-github-actions/auth@v3
|
||||||
|
with:
|
||||||
|
workload_identity_provider: ${{ env.DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
|
||||||
|
service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }}
|
||||||
|
|
||||||
|
- uses: google-github-actions/setup-gcloud@v3
|
||||||
|
with:
|
||||||
|
project_id: ${{ env.PROJECT_ID }}
|
||||||
|
|
||||||
|
- id: deploy
|
||||||
|
name: Deploy bounded GCP staging service
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
api_key_version="$(gcloud secrets versions list "${API_KEY_SECRET}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--filter='state=ENABLED' \
|
||||||
|
--sort-by='~createTime' \
|
||||||
|
--limit=1 \
|
||||||
|
--format='value(name)')"
|
||||||
|
api_key_version="${api_key_version##*/}"
|
||||||
|
[[ "${api_key_version}" =~ ^[0-9]+$ ]]
|
||||||
gcloud run deploy "${SERVICE_NAME}" \
|
gcloud run deploy "${SERVICE_NAME}" \
|
||||||
--project="${PROJECT_ID}" \
|
--project="${PROJECT_ID}" \
|
||||||
--region="${REGION}" \
|
--region="${REGION}" \
|
||||||
|
|
@ -135,39 +195,69 @@ jobs:
|
||||||
--subnet=teleo-staging-europe-west6 \
|
--subnet=teleo-staging-europe-west6 \
|
||||||
--vpc-egress=private-ranges-only \
|
--vpc-egress=private-ranges-only \
|
||||||
--ingress=all \
|
--ingress=all \
|
||||||
--allow-unauthenticated \
|
--no-invoker-iam-check \
|
||||||
--port=8080 \
|
--port=8080 \
|
||||||
--cpu=1 \
|
--cpu=1 \
|
||||||
--memory=512Mi \
|
--memory=512Mi \
|
||||||
--concurrency=8 \
|
--concurrency=8 \
|
||||||
--max-instances=2 \
|
--max-instances=2 \
|
||||||
--timeout=15 \
|
--timeout=15 \
|
||||||
--set-secrets="OBSERVATORY_API_KEY=${API_KEY_SECRET}:latest" \
|
--set-secrets="OBSERVATORY_API_KEY=${API_KEY_SECRET}:${api_key_version}" \
|
||||||
--set-env-vars="GCP_PROJECT_ID=${PROJECT_ID},CLOUD_SQL_INSTANCE=${PROJECT_ID}:${REGION}:teleo-pgvector-standby,DB_NAME=teleo_canonical,DB_IAM_USER=${DB_IAM_USER},DB_AUTHORIZATION_ROLE=kb_observatory_read,SERVICE_REVISION=${GITHUB_SHA}" \
|
--set-env-vars="GCP_PROJECT_ID=${PROJECT_ID},CLOUD_SQL_INSTANCE=${PROJECT_ID}:${REGION}:teleo-pgvector-standby,DB_NAME=teleo_canonical,DB_IAM_USER=${DB_IAM_USER},DB_AUTHORIZATION_ROLE=kb_observatory_read,SERVICE_REVISION=${GITHUB_SHA}" \
|
||||||
--labels="livingip-tier=gcp-staging,livingip-surface=observatory-read-adapter" \
|
--labels="livingip-tier=gcp-staging,livingip-surface=observatory-read-adapter" \
|
||||||
--quiet
|
--quiet
|
||||||
|
printf 'api_key_version=%s\n' "${api_key_version}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Capture positive and negative live receipts
|
- name: Capture positive and negative live receipts
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
API_KEY_VERSION: ${{ steps.deploy.outputs.api_key_version }}
|
||||||
|
EXPECTED_IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
service_url="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format='value(status.url)')"
|
service_json="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format=json)"
|
||||||
revision="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format='value(status.latestReadyRevisionName)')"
|
service_url="$(jq -r '.status.url // empty' <<<"${service_json}")"
|
||||||
api_key="$(gcloud secrets versions access latest --secret="${API_KEY_SECRET}" --project="${PROJECT_ID}")"
|
revision="$(jq -r '.status.latestReadyRevisionName // empty' <<<"${service_json}")"
|
||||||
|
runtime_service_account="$(jq -r '.spec.template.spec.serviceAccountName // .template.serviceAccount // empty' <<<"${service_json}")"
|
||||||
|
deployed_image="$(jq -r '.spec.template.spec.containers[0].image // .template.containers[0].image // empty' <<<"${service_json}")"
|
||||||
|
deployed_secret_version="$(jq -r '
|
||||||
|
[
|
||||||
|
.spec.template.spec.containers[]?.env[]?
|
||||||
|
| select(.name == "OBSERVATORY_API_KEY")
|
||||||
|
| .valueFrom.secretKeyRef.key
|
||||||
|
][0] // [
|
||||||
|
.template.containers[]?.env[]?
|
||||||
|
| select(.name == "OBSERVATORY_API_KEY")
|
||||||
|
| .valueSource.secretKeyRef.version
|
||||||
|
][0] // empty
|
||||||
|
' <<<"${service_json}")"
|
||||||
|
expected_digest="${EXPECTED_IMAGE_REF##*@}"
|
||||||
|
api_key="$(gcloud secrets versions access "${API_KEY_VERSION}" --secret="${API_KEY_SECRET}" --project="${PROJECT_ID}")"
|
||||||
test -n "${service_url}"
|
test -n "${service_url}"
|
||||||
test -n "${revision}"
|
test -n "${revision}"
|
||||||
|
test "${runtime_service_account}" = "${RUNTIME_SERVICE_ACCOUNT}"
|
||||||
|
test "${deployed_secret_version}" = "${API_KEY_VERSION}"
|
||||||
|
[[ "${deployed_image}" == "${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}@${expected_digest}" ]]
|
||||||
test -n "${api_key}"
|
test -n "${api_key}"
|
||||||
|
|
||||||
curl --fail --silent --show-error \
|
curl --fail --silent --show-error \
|
||||||
--header "X-Api-Key: ${api_key}" \
|
--header "X-Api-Key: ${api_key}" \
|
||||||
"${service_url}/v1/claims/sample" > positive.json
|
"${service_url}/v1/claims/sample" > positive.json
|
||||||
jq -e '
|
jq -e \
|
||||||
|
--arg project_id "${PROJECT_ID}" \
|
||||||
|
--arg cloud_sql_instance "${PROJECT_ID}:${REGION}:teleo-pgvector-standby" \
|
||||||
|
--arg db_iam_user "${DB_IAM_USER}" \
|
||||||
|
--arg git_sha "${GITHUB_SHA}" '
|
||||||
.schema == "livingip.observatory-canonical-claim.v1"
|
.schema == "livingip.observatory-canonical-claim.v1"
|
||||||
and .read_only == true
|
and .read_only == true
|
||||||
|
and .provenance.gcp_project == $project_id
|
||||||
|
and .provenance.cloud_sql_instance == $cloud_sql_instance
|
||||||
and .provenance.database == "teleo_canonical"
|
and .provenance.database == "teleo_canonical"
|
||||||
|
and .provenance.database_principal == $db_iam_user
|
||||||
and .provenance.authorization_role == "kb_observatory_read"
|
and .provenance.authorization_role == "kb_observatory_read"
|
||||||
and .provenance.transaction_read_only == true
|
and .provenance.transaction_read_only == true
|
||||||
and .provenance.write_privileges_denied == true
|
and .provenance.write_privileges_denied == true
|
||||||
|
and .provenance.service_revision == $git_sha
|
||||||
and (.canonical.evidence | length) > 0
|
and (.canonical.evidence | length) > 0
|
||||||
and .proposal_ledger.distinct_from_canonical == true
|
and .proposal_ledger.distinct_from_canonical == true
|
||||||
' positive.json >/dev/null
|
' positive.json >/dev/null
|
||||||
|
|
@ -178,12 +268,23 @@ jobs:
|
||||||
--data '{"status":"applied"}' "${service_url}/v1/claims/sample")"
|
--data '{"status":"applied"}' "${service_url}/v1/claims/sample")"
|
||||||
test "${anonymous_status}" = 401
|
test "${anonymous_status}" = 401
|
||||||
test "${write_status}" = 405
|
test "${write_status}" = 405
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--header "X-Api-Key: ${api_key}" \
|
||||||
|
"${service_url}/v1/claims/sample" > after-write-attempt.json
|
||||||
|
cmp <(jq -S . positive.json) <(jq -S . after-write-attempt.json)
|
||||||
|
|
||||||
|
jq -f ops/redact_observatory_read_adapter_receipt.jq \
|
||||||
|
positive.json > positive-redacted.json
|
||||||
|
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg service_url "${service_url}" \
|
--arg service_url "${service_url}" \
|
||||||
--arg revision "${revision}" \
|
--arg revision "${revision}" \
|
||||||
--arg git_sha "${GITHUB_SHA}" \
|
--arg git_sha "${GITHUB_SHA}" \
|
||||||
--argjson positive "$(cat positive.json)" \
|
--arg runtime_service_account "${runtime_service_account}" \
|
||||||
|
--arg database_principal "${DB_IAM_USER}" \
|
||||||
|
--arg deployed_image "${deployed_image}" \
|
||||||
|
--arg api_key_secret_version "${API_KEY_VERSION}" \
|
||||||
|
--argjson positive "$(cat positive-redacted.json)" \
|
||||||
--arg anonymous_status "${anonymous_status}" \
|
--arg anonymous_status "${anonymous_status}" \
|
||||||
--arg write_status "${write_status}" \
|
--arg write_status "${write_status}" \
|
||||||
'{
|
'{
|
||||||
|
|
@ -192,14 +293,21 @@ jobs:
|
||||||
service_url: $service_url,
|
service_url: $service_url,
|
||||||
revision: $revision,
|
revision: $revision,
|
||||||
git_sha: $git_sha,
|
git_sha: $git_sha,
|
||||||
|
runtime_service_account: $runtime_service_account,
|
||||||
|
database_principal: $database_principal,
|
||||||
|
deployed_image: $deployed_image,
|
||||||
|
api_key_secret_version: $api_key_secret_version,
|
||||||
positive: $positive,
|
positive: $positive,
|
||||||
negative: {
|
negative: {
|
||||||
anonymous_http_status: ($anonymous_status | tonumber),
|
anonymous_http_status: ($anonymous_status | tonumber),
|
||||||
authenticated_post_http_status: ($write_status | tonumber)
|
authenticated_post_http_status: ($write_status | tonumber),
|
||||||
|
canonical_state_unchanged_after_post: true,
|
||||||
|
database_write_privileges_denied: $positive.provenance.write_privileges_denied
|
||||||
},
|
},
|
||||||
production_repoint_executed: false
|
production_repoint_executed: false
|
||||||
}' > observatory-read-adapter-live-receipt.json
|
}' > observatory-read-adapter-live-receipt.json
|
||||||
unset api_key
|
unset api_key
|
||||||
|
rm -f positive.json positive-redacted.json after-write-attempt.json anonymous.json write.json
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@ it does not change Argus or PR #148's Leo runtime files.
|
||||||
- Direct VPC egress: `teleo-staging-net` / `teleo-staging-europe-west6`.
|
- Direct VPC egress: `teleo-staging-net` / `teleo-staging-europe-west6`.
|
||||||
- Cloud SQL: `teleo-501523:europe-west6:teleo-pgvector-standby`, private IP.
|
- Cloud SQL: `teleo-501523:europe-west6:teleo-pgvector-standby`, private IP.
|
||||||
- Database: `teleo_canonical` only.
|
- Database: `teleo_canonical` only.
|
||||||
|
- Build identity: `sa-artifact-builder@teleo-501523.iam.gserviceaccount.com`;
|
||||||
|
image build/push only.
|
||||||
|
- Staging deploy/canary identity:
|
||||||
|
`sa-observatory-deployer@teleo-501523.iam.gserviceaccount.com`; an exact
|
||||||
|
workflow/main-only WIF provider, a five-permission Cloud Run custom role,
|
||||||
|
repository read, exact runtime `actAs`, and exact secret access. Never grant
|
||||||
|
deploy permissions to the artifact builder.
|
||||||
- Runtime identity: `sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com`.
|
- Runtime identity: `sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com`.
|
||||||
- IAM database user: `sa-observatory-read-adapter@teleo-501523.iam`.
|
- IAM database user: `sa-observatory-read-adapter@teleo-501523.iam`.
|
||||||
- Database authorization role: `kb_observatory_read` (`NOLOGIN`).
|
- Database authorization role: `kb_observatory_read` (`NOLOGIN`).
|
||||||
|
|
@ -52,24 +59,103 @@ effective write-denial checks drift.
|
||||||
These are staging mutations. Run them only with an authenticated GCP operator;
|
These are staging mutations. Run them only with an authenticated GCP operator;
|
||||||
they do not route production traffic.
|
they do not route production traffic.
|
||||||
|
|
||||||
1. Create the dedicated runtime service account and grant only
|
1. Reauthenticate the project operator, then run the first full direct check:
|
||||||
`roles/cloudsql.client` and `roles/cloudsql.instanceUser` in project
|
|
||||||
`teleo-501523`.
|
```bash
|
||||||
2. Add the service account as a Cloud SQL IAM service-account user on
|
gcloud projects describe teleo-501523 --format=value(projectId)
|
||||||
`teleo-pgvector-standby`.
|
python3 ops/check_gcp_infra_readiness.py
|
||||||
3. From the existing private GCP VM database-admin path, run
|
```
|
||||||
|
|
||||||
|
2. Review the exact dry-run packet. It enables only required APIs, creates
|
||||||
|
separate staging deploy/runtime identities, creates the dedicated
|
||||||
|
`observatory-read-adapter-main` WIF provider, keeps the artifact builder
|
||||||
|
narrow, scopes Cloud SQL connect/login to `teleo-pgvector-standby`, and
|
||||||
|
scopes secret access to `observatory-read-api-key-staging`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ops/plan_observatory_read_adapter_gcp.py
|
||||||
|
python3 ops/plan_observatory_read_adapter_gcp.py --format shell
|
||||||
|
```
|
||||||
|
|
||||||
|
The shell output is unexecuted and uses `set -euo pipefail`. Apply it only
|
||||||
|
after review with an authenticated `teleo-501523` administrator. The
|
||||||
|
deployer custom role contains only `run.services.create`,
|
||||||
|
`run.services.update`, `run.services.get`, `run.services.setIamPolicy`, and
|
||||||
|
`run.operations.get`; it contains no delete permission. Do not add
|
||||||
|
`run.admin`, `run.developer`, `cloudsql.admin`, `secretmanager.admin`,
|
||||||
|
`compute.admin`, Editor, or Owner to either lane identity.
|
||||||
|
3. Add the dedicated runtime service account as a Cloud SQL IAM
|
||||||
|
service-account user on `teleo-pgvector-standby`.
|
||||||
|
4. From the existing private GCP VM database-admin path, run
|
||||||
`ops/observatory_read_role.sql` against `teleo_canonical` with
|
`ops/observatory_read_role.sql` against `teleo_canonical` with
|
||||||
`OBSERVATORY_DB_IAM_USER=sa-observatory-read-adapter@teleo-501523.iam`.
|
`OBSERVATORY_DB_IAM_USER=sa-observatory-read-adapter@teleo-501523.iam`.
|
||||||
Do not read, copy, or retain the administrator password.
|
Do not read, copy, or retain the administrator password.
|
||||||
4. Create `observatory-read-api-key-staging`, add a generated 32-byte-or-longer
|
5. Create `observatory-read-api-key-staging`, add a generated whitespace-free
|
||||||
value through stdin, and grant that secret's accessor role only to the
|
32-byte-or-longer value through stdin only when no enabled version exists,
|
||||||
runtime service account and the bounded staging canary identity.
|
and grant that secret's accessor role only to the runtime service account
|
||||||
5. Dispatch `.github/workflows/gcp-observatory-read-adapter.yml` with
|
and the dedicated staging deploy/canary identity. Deployment pins the exact
|
||||||
`action=deploy_staging` from the exact reviewed branch revision.
|
enabled version; it does not inject `latest` into Cloud Run.
|
||||||
|
6. Dispatch the read-only preflight from merged `main`:
|
||||||
|
|
||||||
The workflow builds an immutable image, deploys at most two Cloud Run
|
```bash
|
||||||
|
gh workflow run gcp-observatory-read-adapter.yml \
|
||||||
|
--repo living-ip/teleo-infrastructure \
|
||||||
|
--ref main \
|
||||||
|
-f action=preflight_staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Require a passing `observatory-read-adapter-preflight` artifact before a
|
||||||
|
bounded `action=deploy_staging` dispatch. The preflight verifies the exact
|
||||||
|
WIF condition, custom-role permission sets, project/resource IAM bindings,
|
||||||
|
Cloud Run service agent, private VPC/subnet relationship, private-only Cloud
|
||||||
|
SQL network and IAM authentication, trimmed IAM database user, exact secret
|
||||||
|
policy/value shape, and the run-unique immutable image. It also rejects any
|
||||||
|
artifact-builder deploy, runtime `actAs`, secret, or service binding.
|
||||||
|
|
||||||
|
The workflow always builds a run-unique immutable image, deploys at most two Cloud Run
|
||||||
instances, and retains the positive response plus anonymous `401` and
|
instances, and retains the positive response plus anonymous `401` and
|
||||||
authenticated POST `405` receipts. It does not modify Vercel.
|
authenticated POST `405` receipts. The retained positive receipt contains
|
||||||
|
identity/provenance, counts, and proposal/live state only; claim text, source
|
||||||
|
URLs, excerpts, unknown future provenance fields, and secret values are
|
||||||
|
removed by an explicit tested allowlist. The live gate binds the response to
|
||||||
|
the exact project, private instance, database principal, Git revision, Cloud
|
||||||
|
Run image digest, runtime identity, and pinned secret version. It does not
|
||||||
|
modify Vercel. The app-level `X-Api-Key` remains the staging authentication
|
||||||
|
boundary, so anonymous Cloud Run invocation reaches the adapter and returns
|
||||||
|
the required `401` instead of a platform-generated status.
|
||||||
|
|
||||||
|
## Current T2 Receipt And Exact T3 Gate
|
||||||
|
|
||||||
|
GitHub Actions run `29389090997` proves the existing `living-ip-github` WIF
|
||||||
|
provider can federate as the artifact builder, run the checker, upload its
|
||||||
|
artifact, and clean up credentials. The checker reported `8` pass, `10`
|
||||||
|
blocked, `0` fail. Eight blocks are missing inventory permissions on the
|
||||||
|
artifact-builder identity; two are missing restore/replication proof. This is
|
||||||
|
partial OIDC/T2 evidence, not T3 and not authorization to deploy with that
|
||||||
|
identity.
|
||||||
|
|
||||||
|
The exact external action is for `billy@livingip.xyz` to reauthenticate gcloud
|
||||||
|
and ADC, prove `gcloud projects describe teleo-501523`, run
|
||||||
|
`python3 ops/check_gcp_infra_readiness.py`, review and execute the planner's
|
||||||
|
shell output as a project IAM/API administrator, then apply
|
||||||
|
`ops/observatory_read_role.sql` from the existing private database-admin path.
|
||||||
|
The general readiness identity and artifact-builder identity are not deployer
|
||||||
|
substitutes. After those actions, dispatch `preflight_staging`; only a passing
|
||||||
|
artifact permits `deploy_staging`. A passing preflight remains T2 setup proof:
|
||||||
|
the fail-closed live claim and negative receipts are what establish T3.
|
||||||
|
|
||||||
|
The IAM basis is the Google Cloud deployment contract for Cloud Run (Cloud Run
|
||||||
|
access, Artifact Registry repository read, and exact runtime `actAs`), Secret
|
||||||
|
Manager resource-level accessor grants, Direct VPC access through the default
|
||||||
|
Cloud Run service agent, and conditional Cloud SQL client access to one named
|
||||||
|
instance:
|
||||||
|
|
||||||
|
- https://docs.cloud.google.com/run/docs/deploying
|
||||||
|
- https://docs.cloud.google.com/run/docs/authenticating/public
|
||||||
|
- https://docs.cloud.google.com/run/docs/configuring/vpc-direct-vpc
|
||||||
|
- https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines
|
||||||
|
- https://docs.cloud.google.com/secret-manager/docs/manage-access-to-secrets
|
||||||
|
- https://docs.cloud.google.com/sql/docs/postgres/iam-conditions
|
||||||
|
|
||||||
## Unexecuted Cutover Packet
|
## Unexecuted Cutover Packet
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
{
|
||||||
|
"schema": "livingip.observatoryReadAdapterOidcReadinessReceipt.v1",
|
||||||
|
"status": "partial_oidc_t2",
|
||||||
|
"required_runtime_tier": "T3_live_gcp_staging_api",
|
||||||
|
"current_runtime_tier": "T2_authenticated_github_oidc_partial_inventory",
|
||||||
|
"workflow": {
|
||||||
|
"run_id": 29389090997,
|
||||||
|
"url": "https://github.com/living-ip/teleo-infrastructure/actions/runs/29389090997",
|
||||||
|
"revision": "33399bd9acfa09e4e5409873e86c8c46ac694a8d",
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"identity": "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com",
|
||||||
|
"wif_authentication": "pass",
|
||||||
|
"gcloud_setup": "pass",
|
||||||
|
"checker_execution": "pass",
|
||||||
|
"artifact_upload": "pass",
|
||||||
|
"credential_cleanup": "pass",
|
||||||
|
"job_conclusion": "failure_due_to_blocked_readiness_enforcement"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"pass_count": 8,
|
||||||
|
"blocked_count": 10,
|
||||||
|
"fail_count": 0,
|
||||||
|
"passed_checks": [
|
||||||
|
"artifact_repositories",
|
||||||
|
"cloud_build_contract",
|
||||||
|
"github_actions_readiness_ci",
|
||||||
|
"gcp_cloudsql_restore_drill_contract",
|
||||||
|
"canonical_postgres_restore_contract",
|
||||||
|
"local_canonical_postgres_restore_preflight",
|
||||||
|
"gcp_runtime_baseline_contract",
|
||||||
|
"gcp_service_communications_contract"
|
||||||
|
],
|
||||||
|
"permission_or_resource_read_blocks": [
|
||||||
|
{
|
||||||
|
"check": "cloud_build_service_account",
|
||||||
|
"missing_permission": "iam.serviceAccounts.get"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "github_actions_artifact_ci",
|
||||||
|
"missing_permission": "iam.workloadIdentityPoolProviders.get"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "network_ingress",
|
||||||
|
"missing_permission": "compute.firewalls.list"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "compute_runtime_service_accounts",
|
||||||
|
"missing_permission": "compute.instances.list"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "compute_disk_snapshots",
|
||||||
|
"missing_permission": "compute.resourcePolicies.get"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "backup_buckets",
|
||||||
|
"missing_permission": "bucket metadata read denied; exact permission was not retained by the checker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "cloud_sql_standby_target",
|
||||||
|
"missing_permission": "Cloud SQL instance read denied or target invisible to this identity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "kb_source_restore_access",
|
||||||
|
"missing_permission": "secretmanager.secrets.list"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evidence_blocks": [
|
||||||
|
"local_sqlite_postgres_restore_canary",
|
||||||
|
"kb_restore_or_replication"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"security_readback": {
|
||||||
|
"artifact_builder_deploy_authorized": false,
|
||||||
|
"cloud_run_service_created": false,
|
||||||
|
"cloud_sql_queried": false,
|
||||||
|
"secret_values_logged": false,
|
||||||
|
"production_or_vercel_routing_changed": false
|
||||||
|
},
|
||||||
|
"source_artifact": {
|
||||||
|
"github_run_url": "https://github.com/living-ip/teleo-infrastructure/actions/runs/29389090997",
|
||||||
|
"artifact_name": "gcp-readiness",
|
||||||
|
"summary_sha256": "6a1be356c069b8affacc492ac3b1e1ca63b917f92a32d7d886511a226c52925b",
|
||||||
|
"checker_sha256": "56ce4e8ba655a47e6e620a2c7039ee54fa10b1c53431d1dc6ec4db5189bb17f5",
|
||||||
|
"local_download_path": "/private/tmp/observatory-read-adapter-oidc-29389090997/gcp-readiness"
|
||||||
|
},
|
||||||
|
"proof_paths": [
|
||||||
|
"docs/reports/observatory-read-adapter/gcp-oidc-readiness-29389090997-redacted.json",
|
||||||
|
"https://github.com/living-ip/teleo-infrastructure/actions/runs/29389090997"
|
||||||
|
],
|
||||||
|
"claim_ceiling": "GitHub OIDC and the artifact-only identity work. The receipt does not prove broad GCP inventory, adapter prerequisites, a live Cloud Run service, or a teleo_canonical query."
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
{
|
{
|
||||||
"schema": "livingip.observatoryReadAdapterCurrentGate.v1",
|
"schema": "livingip.observatoryReadAdapterCurrentGate.v1",
|
||||||
"current_canary": "Authenticated GET /v1/claims/sample in GCP staging returns a provenance-bearing teleo_canonical claim, anonymous GET returns 401, and authenticated POST returns 405.",
|
"current_canary": "Authenticated GET /v1/claims/sample in GCP staging returns a provenance-bearing teleo_canonical claim, anonymous GET returns 401, and authenticated POST returns 405.",
|
||||||
"permission_profile": "approval_policy_never_with_unrestricted_filesystem_and_network",
|
"required_runtime_tier": "T3_live_gcp_staging_api",
|
||||||
|
"current_runtime_tier": "T2_authenticated_github_oidc_partial_inventory",
|
||||||
"attempted_routes": [
|
"attempted_routes": [
|
||||||
{
|
{
|
||||||
"route": "merged GitHub Actions deployment with artifact-builder WIF",
|
"route": "merged GitHub Actions deployment with artifact-builder WIF",
|
||||||
"result": "image build, smoke, and push passed; deploy stopped because run.googleapis.com is disabled"
|
"result": "OIDC, image build, smoke, and push passed; the 29382683335 deploy stopped before resource creation because run.googleapis.com was disabled"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "local billy@livingip.xyz gcloud user credential",
|
"route": "GitHub Actions GCP readiness with artifact-builder WIF, run 29389090997",
|
||||||
"result": "token refresh and service enable both stop at noninteractive Google reauthentication"
|
"result": "federation, gcloud setup, checker, artifact upload, and credential cleanup passed; checker result was 8 pass, 10 blocked, 0 fail"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "local application-default credential",
|
"route": "local billy@livingip.xyz gcloud user and application-default credentials",
|
||||||
"result": "token refresh stops at noninteractive Google reauthentication"
|
"result": "both require interactive Google reauthentication before project-admin API or IAM changes"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "other locally authenticated Google accounts",
|
"route": "other locally authenticated Google accounts",
|
||||||
|
|
@ -22,19 +23,30 @@
|
||||||
{
|
{
|
||||||
"route": "fixed read-only GCP IAP status workflow",
|
"route": "fixed read-only GCP IAP status workflow",
|
||||||
"result": "WIF token exchange failed because the teleo-iap-operator provider target is missing or disabled"
|
"result": "WIF token exchange failed because the teleo-iap-operator provider target is missing or disabled"
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "local browser or computer-use session",
|
|
||||||
"result": "no browser or computer-use tool is exposed to this task; no foreground takeover was attempted"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"exact_gate": {
|
"exact_gate": {
|
||||||
"first_infrastructure_gate": "Cloud Run Admin API run.googleapis.com is disabled in teleo-501523.",
|
"non_gui_federation": "Working. sa-artifact-builder can federate through living-ip-github and read/push Artifact Registry resources.",
|
||||||
"current_authorization_gate": "The only local account authorized for teleo-501523, billy@livingip.xyz, requires Google password or MFA reauthentication before project APIs and staging IAM resources can be changed.",
|
"artifact_builder_scope": "Intentionally insufficient for deployment or full inventory. Do not grant it Cloud Run, Cloud SQL, Compute, IAM, or Secret Manager deployment roles.",
|
||||||
"alternate_private_operator_gate": "The read-only teleo-iap-operator WIF provider returns invalid_target and cannot reach its status operation."
|
"last_api_readback": "The last direct deployment reported run.googleapis.com disabled. The current artifact-builder identity cannot verify or enable the required project APIs.",
|
||||||
|
"t3_bootstrap": "An authenticated teleo-501523 administrator must review and apply ops/plan_observatory_read_adapter_gcp.py, including required APIs, dedicated deploy/runtime identities, the exact workflow/main-only observatory-read-adapter-main WIF provider, five-permission Cloud Run custom role, exact Cloud SQL instance grants, exact secret grants, trimmed IAM database user, and private database role SQL.",
|
||||||
|
"local_operator": "billy@livingip.xyz requires Google password or MFA reauthentication before that administrator action can run.",
|
||||||
|
"alternate_private_operator": "The fixed teleo-iap-operator provider is absent or disabled and is not an available substitute."
|
||||||
},
|
},
|
||||||
"why_autonomous_repair_stops": "Google password or MFA reauthentication is a human-only credential boundary, and no authenticated project-admin browser or computer-use surface is available in this task.",
|
"latest_direct_non_gui_check": {
|
||||||
"clear_CTA": "Run `gcloud auth login billy@livingip.xyz --update-adc`, complete Google password or MFA, and then report `GCP reauth complete`.",
|
"active_account": "billy@livingip.xyz",
|
||||||
"next_non_user_action": "Enable the required staging APIs; create or verify the runtime service account, API-key secret, Cloud SQL IAM database user, and exact read-only role grants; deploy the retained immutable image; capture authenticated GET, anonymous 401, authenticated POST 405, SQL write-denial, exact service/database identity, and cleanup receipts.",
|
"gcloud_projects_describe": "blocked: reauthentication failed and non-interactive execution cannot prompt",
|
||||||
"production_boundary": "Do not change Vercel, production routing, Cloud SQL network exposure, or any write endpoint."
|
"application_default_credentials": "blocked: reauthentication failed and non-interactive execution cannot prompt",
|
||||||
|
"full_readiness_checker_executed": false,
|
||||||
|
"why_not": "The required first command, gcloud projects describe teleo-501523, did not pass."
|
||||||
|
},
|
||||||
|
"why_t3_stops": "A live staging service requires authorized API, IAM, Secret Manager, Cloud SQL IAM-user, and private database-role mutations. Working artifact-builder federation does not authorize those actions.",
|
||||||
|
"clear_CTA": "Run `gcloud auth login billy@livingip.xyz --update-adc`, then `gcloud projects describe teleo-501523 --format=value(projectId)` and `python3 ops/check_gcp_infra_readiness.py`; review and apply the exact adapter packet emitted by `python3 ops/plan_observatory_read_adapter_gcp.py --format shell`.",
|
||||||
|
"next_non_user_action": "Dispatch action=preflight_staging from merged main using sa-observatory-deployer. Only after its redacted preflight artifact passes, dispatch the bounded staging deploy and capture authenticated GET, anonymous 401, authenticated POST 405, database write-denial, exact service/database identity, and cleanup receipts.",
|
||||||
|
"production_boundary": "Do not change Vercel, production routing, Cloud SQL network exposure, Telegram, x402, or any write endpoint.",
|
||||||
|
"proof_paths": [
|
||||||
|
"docs/reports/observatory-read-adapter/gcp-oidc-readiness-29389090997-redacted.json",
|
||||||
|
"docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json",
|
||||||
|
"https://github.com/living-ip/teleo-infrastructure/actions/runs/29389090997"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"schema": "livingip.observatoryReadAdapterStagingReceipt.v1",
|
"schema": "livingip.observatoryReadAdapterStagingReceipt.v1",
|
||||||
"status": "blocked_before_runtime_creation",
|
"status": "blocked_before_runtime_creation",
|
||||||
"required_runtime_tier": "T3_live_gcp_staging_api",
|
"required_runtime_tier": "T3_live_gcp_staging_api",
|
||||||
"current_runtime_tier": "below_T3_build_artifact_only",
|
"current_runtime_tier": "T2_authenticated_github_oidc_partial_inventory",
|
||||||
"project_id": "teleo-501523",
|
"project_id": "teleo-501523",
|
||||||
"region": "europe-west6",
|
"region": "europe-west6",
|
||||||
"service_name": "observatory-read-adapter-staging",
|
"service_name": "observatory-read-adapter-staging",
|
||||||
|
|
@ -26,6 +26,18 @@
|
||||||
"failure_detail": "run.googleapis.com is disabled in teleo-501523",
|
"failure_detail": "run.googleapis.com is disabled in teleo-501523",
|
||||||
"authenticated_as": "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com"
|
"authenticated_as": "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com"
|
||||||
},
|
},
|
||||||
|
"readiness_reconciliation": {
|
||||||
|
"run_id": 29389090997,
|
||||||
|
"url": "https://github.com/living-ip/teleo-infrastructure/actions/runs/29389090997",
|
||||||
|
"identity": "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com",
|
||||||
|
"wif_authentication": "pass",
|
||||||
|
"checker_result": {
|
||||||
|
"pass_count": 8,
|
||||||
|
"blocked_count": 10,
|
||||||
|
"fail_count": 0
|
||||||
|
},
|
||||||
|
"claim": "working federation with an intentionally narrow artifact identity; not a staging deploy or full inventory"
|
||||||
|
},
|
||||||
"live_receipts": {
|
"live_receipts": {
|
||||||
"service_url": null,
|
"service_url": null,
|
||||||
"authenticated_get_sample": null,
|
"authenticated_get_sample": null,
|
||||||
|
|
@ -47,9 +59,26 @@
|
||||||
"orphan_local_build_processes": 0,
|
"orphan_local_build_processes": 0,
|
||||||
"artifact_registry_image_retained_for_next_deploy": true
|
"artifact_registry_image_retained_for_next_deploy": true
|
||||||
},
|
},
|
||||||
"proof_paths": [
|
"source_artifacts": [
|
||||||
"/private/tmp/observatory-read-adapter-run-29382683335/observatory-adapter-image/observatory-adapter-image.txt",
|
{
|
||||||
"/private/tmp/observatory-iap-status-29382844567/gcp-iap-operator-29382844567/result.json"
|
"run_url": "https://github.com/living-ip/teleo-infrastructure/actions/runs/29382683335",
|
||||||
|
"artifact_name": "observatory-adapter-image",
|
||||||
|
"sha256": "6d958b8d345ec878db2f70aaab5fad4318b106441983bd7f8e9170a429f4e852"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_url": "https://github.com/living-ip/teleo-infrastructure/actions/runs/29382844567",
|
||||||
|
"artifact_name": "gcp-iap-operator-29382844567",
|
||||||
|
"sha256": "988f77123f7624469c15e3c38bc1e8864a1e9f61e571feb5f6c838e67fe039e9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run_url": "https://github.com/living-ip/teleo-infrastructure/actions/runs/29389090997",
|
||||||
|
"artifact_name": "gcp-readiness",
|
||||||
|
"sha256": "6a1be356c069b8affacc492ac3b1e1ca63b917f92a32d7d886511a226c52925b"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"claim_ceiling": "The immutable staging image exists, but no live API or database query receipt exists. T3 is not complete."
|
"proof_paths": [
|
||||||
|
"docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json",
|
||||||
|
"docs/reports/observatory-read-adapter/gcp-oidc-readiness-29389090997-redacted.json"
|
||||||
|
],
|
||||||
|
"claim_ceiling": "The immutable staging image and working artifact-only OIDC path exist, but no live API or database query receipt exists. T3 is not complete."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
669
ops/check_observatory_read_adapter_gcp_preflight.py
Normal file
669
ops/check_observatory_read_adapter_gcp_preflight.py
Normal file
|
|
@ -0,0 +1,669 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run the adapter-specific, non-mutating GCP staging preflight."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT = "teleo-501523"
|
||||||
|
PROJECT_NUMBER = "785938879453"
|
||||||
|
REGION = "europe-west6"
|
||||||
|
NETWORK = "teleo-staging-net"
|
||||||
|
SUBNET = "teleo-staging-europe-west6"
|
||||||
|
INSTANCE = "teleo-pgvector-standby"
|
||||||
|
SERVICE = "observatory-read-adapter-staging"
|
||||||
|
DEPLOY_SERVICE_ACCOUNT = f"sa-observatory-deployer@{PROJECT}.iam.gserviceaccount.com"
|
||||||
|
RUNTIME_SERVICE_ACCOUNT = f"sa-observatory-read-adapter@{PROJECT}.iam.gserviceaccount.com"
|
||||||
|
BUILD_SERVICE_ACCOUNT = f"sa-artifact-builder@{PROJECT}.iam.gserviceaccount.com"
|
||||||
|
DB_IAM_USER = f"sa-observatory-read-adapter@{PROJECT}.iam"
|
||||||
|
SECRET = "observatory-read-api-key-staging"
|
||||||
|
REPOSITORY = "teleo"
|
||||||
|
WIF_POOL = "github-actions"
|
||||||
|
DEPLOY_WIF_PROVIDER = "observatory-read-adapter-main"
|
||||||
|
DEPLOY_WORKFLOW_REF = (
|
||||||
|
"living-ip/teleo-infrastructure/.github/workflows/"
|
||||||
|
"gcp-observatory-read-adapter.yml@refs/heads/main"
|
||||||
|
)
|
||||||
|
DEPLOY_WIF_ATTRIBUTE_CONDITION = (
|
||||||
|
"assertion.repository=='living-ip/teleo-infrastructure' && "
|
||||||
|
"assertion.ref=='refs/heads/main' && "
|
||||||
|
f"assertion.workflow_ref=='{DEPLOY_WORKFLOW_REF}'"
|
||||||
|
)
|
||||||
|
DEPLOY_WIF_MEMBER = (
|
||||||
|
f"principal://iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/"
|
||||||
|
f"workloadIdentityPools/{WIF_POOL}/subject/"
|
||||||
|
"repo:living-ip/teleo-infrastructure:ref:refs/heads/main"
|
||||||
|
)
|
||||||
|
PREFLIGHT_ROLE = f"projects/{PROJECT}/roles/observatoryStagingPreflight"
|
||||||
|
DEPLOY_ROLE = f"projects/{PROJECT}/roles/observatoryStagingDeployer"
|
||||||
|
PREFLIGHT_PERMISSIONS = {
|
||||||
|
"artifactregistry.repositories.getIamPolicy",
|
||||||
|
"cloudsql.instances.get",
|
||||||
|
"cloudsql.users.list",
|
||||||
|
"compute.networks.get",
|
||||||
|
"compute.subnetworks.get",
|
||||||
|
"iam.serviceAccounts.get",
|
||||||
|
"iam.serviceAccounts.getIamPolicy",
|
||||||
|
"iam.roles.get",
|
||||||
|
"iam.workloadIdentityPoolProviders.get",
|
||||||
|
"resourcemanager.projects.get",
|
||||||
|
"resourcemanager.projects.getIamPolicy",
|
||||||
|
"run.operations.get",
|
||||||
|
"run.services.get",
|
||||||
|
"run.services.getIamPolicy",
|
||||||
|
"secretmanager.secrets.getIamPolicy",
|
||||||
|
"serviceusage.services.get",
|
||||||
|
"serviceusage.services.use",
|
||||||
|
}
|
||||||
|
DEPLOY_PERMISSIONS = {
|
||||||
|
"run.operations.get",
|
||||||
|
"run.services.create",
|
||||||
|
"run.services.get",
|
||||||
|
"run.services.setIamPolicy",
|
||||||
|
"run.services.update",
|
||||||
|
}
|
||||||
|
REQUIRED_APIS = (
|
||||||
|
"artifactregistry.googleapis.com",
|
||||||
|
"compute.googleapis.com",
|
||||||
|
"iamcredentials.googleapis.com",
|
||||||
|
"run.googleapis.com",
|
||||||
|
"secretmanager.googleapis.com",
|
||||||
|
"sqladmin.googleapis.com",
|
||||||
|
"sts.googleapis.com",
|
||||||
|
)
|
||||||
|
IMAGE_RE = re.compile(
|
||||||
|
rf"^europe-west6-docker[.]pkg[.]dev/{PROJECT}/teleo/observatory-read-adapter:"
|
||||||
|
r"[0-9a-f]{40}-[0-9]+-[1-9][0-9]*@sha256:[0-9a-f]{64}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Check:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_error(value: str) -> str:
|
||||||
|
value = re.sub(r"/[^\s]*/gha-creds-[^\s]+[.]json", "<ephemeral-wif-credential>", value)
|
||||||
|
return " ".join(value.strip().split())[-600:] or "command_failed_without_stderr"
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
try:
|
||||||
|
return subprocess.run(command, text=True, capture_output=True, timeout=30, check=False)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return subprocess.CompletedProcess(command, 124, stdout="", stderr="command_timed_out_after_30_seconds")
|
||||||
|
except OSError as exc:
|
||||||
|
return subprocess.CompletedProcess(command, 127, stdout="", stderr=f"command_unavailable:{exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def command_check(
|
||||||
|
name: str,
|
||||||
|
command: list[str],
|
||||||
|
validate: Callable[[str], bool],
|
||||||
|
success_detail: str,
|
||||||
|
) -> Check:
|
||||||
|
result = run(command)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return Check(name, "blocked", sanitize_error(result.stderr))
|
||||||
|
try:
|
||||||
|
valid = validate(result.stdout)
|
||||||
|
except (AttributeError, IndexError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
return Check(name, "blocked", f"invalid_readback:{type(exc).__name__}")
|
||||||
|
return Check(name, "pass" if valid else "blocked", success_detail if valid else "unexpected_readback")
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_sql_is_private_and_iam_enabled(value: str) -> bool:
|
||||||
|
payload = json.loads(value)
|
||||||
|
flags = {
|
||||||
|
item.get("name"): str(item.get("value", "")).lower()
|
||||||
|
for item in payload.get("settings", {}).get("databaseFlags", [])
|
||||||
|
}
|
||||||
|
ip_configuration = payload.get("settings", {}).get("ipConfiguration", {})
|
||||||
|
address_types = {item.get("type") for item in payload.get("ipAddresses", [])}
|
||||||
|
return (
|
||||||
|
payload.get("name") == INSTANCE
|
||||||
|
and payload.get("region") == REGION
|
||||||
|
and str(payload.get("databaseVersion", "")).startswith("POSTGRES_")
|
||||||
|
and ip_configuration.get("ipv4Enabled") is False
|
||||||
|
and str(ip_configuration.get("privateNetwork", "")).endswith(f"/networks/{NETWORK}")
|
||||||
|
and "PRIVATE" in address_types
|
||||||
|
and "PRIMARY" not in address_types
|
||||||
|
and flags.get("cloudsql.iam_authentication") == "on"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_sql_iam_user_exists(value: str) -> bool:
|
||||||
|
users = json.loads(value)
|
||||||
|
return any(
|
||||||
|
user.get("name") == DB_IAM_USER and user.get("type") == "CLOUD_IAM_SERVICE_ACCOUNT" for user in users
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def subnet_is_exact(value: str) -> bool:
|
||||||
|
payload = json.loads(value)
|
||||||
|
return (
|
||||||
|
payload.get("name") == SUBNET
|
||||||
|
and str(payload.get("region", "")).endswith(f"/regions/{REGION}")
|
||||||
|
and str(payload.get("network", "")).endswith(f"/networks/{NETWORK}")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_is_valid(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
encoded = value.encode("ascii")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
return False
|
||||||
|
return value == value.strip() and 32 <= len(encoded) <= 512
|
||||||
|
|
||||||
|
|
||||||
|
def normalized(value: str) -> str:
|
||||||
|
return " ".join(value.split())
|
||||||
|
|
||||||
|
|
||||||
|
def policy_has_binding(
|
||||||
|
policy: dict[str, object],
|
||||||
|
role: str,
|
||||||
|
member: str,
|
||||||
|
*,
|
||||||
|
condition_title: str | None = None,
|
||||||
|
condition_expression: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
bindings = policy.get("bindings", [])
|
||||||
|
if not isinstance(bindings, list):
|
||||||
|
return False
|
||||||
|
for binding in bindings:
|
||||||
|
if not isinstance(binding, dict) or binding.get("role") != role:
|
||||||
|
continue
|
||||||
|
members = binding.get("members", [])
|
||||||
|
if not isinstance(members, list) or member not in members:
|
||||||
|
continue
|
||||||
|
condition = binding.get("condition")
|
||||||
|
if condition_title is None and condition_expression is None:
|
||||||
|
return condition in (None, {})
|
||||||
|
if not isinstance(condition, dict):
|
||||||
|
continue
|
||||||
|
if condition_title is not None and condition.get("title") != condition_title:
|
||||||
|
continue
|
||||||
|
if condition_expression is not None and normalized(str(condition.get("expression", ""))) != normalized(
|
||||||
|
condition_expression
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def member_roles(policy: dict[str, object], member: str) -> set[str]:
|
||||||
|
roles: set[str] = set()
|
||||||
|
bindings = policy.get("bindings", [])
|
||||||
|
if not isinstance(bindings, list):
|
||||||
|
return roles
|
||||||
|
for binding in bindings:
|
||||||
|
if not isinstance(binding, dict):
|
||||||
|
continue
|
||||||
|
members = binding.get("members", [])
|
||||||
|
role = binding.get("role")
|
||||||
|
if isinstance(members, list) and member in members and isinstance(role, str):
|
||||||
|
roles.add(role)
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
def project_iam_is_exact(value: str) -> bool:
|
||||||
|
policy = json.loads(value)
|
||||||
|
deployer = f"serviceAccount:{DEPLOY_SERVICE_ACCOUNT}"
|
||||||
|
runtime = f"serviceAccount:{RUNTIME_SERVICE_ACCOUNT}"
|
||||||
|
builder = f"serviceAccount:{BUILD_SERVICE_ACCOUNT}"
|
||||||
|
service_agent = (
|
||||||
|
f"serviceAccount:service-{PROJECT_NUMBER}@serverless-robot-prod.iam.gserviceaccount.com"
|
||||||
|
)
|
||||||
|
sql_expression = (
|
||||||
|
f"resource.name == 'projects/{PROJECT}/instances/{INSTANCE}' && "
|
||||||
|
"resource.service == 'sqladmin.googleapis.com'"
|
||||||
|
)
|
||||||
|
forbidden_deployer = {
|
||||||
|
"roles/artifactregistry.writer",
|
||||||
|
"roles/cloudsql.admin",
|
||||||
|
"roles/compute.admin",
|
||||||
|
"roles/editor",
|
||||||
|
"roles/owner",
|
||||||
|
"roles/run.admin",
|
||||||
|
"roles/run.developer",
|
||||||
|
"roles/secretmanager.admin",
|
||||||
|
}
|
||||||
|
forbidden_builder = forbidden_deployer | {
|
||||||
|
DEPLOY_ROLE,
|
||||||
|
PREFLIGHT_ROLE,
|
||||||
|
"roles/iam.serviceAccountUser",
|
||||||
|
"roles/secretmanager.secretAccessor",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
policy_has_binding(policy, PREFLIGHT_ROLE, deployer)
|
||||||
|
and policy_has_binding(policy, DEPLOY_ROLE, deployer)
|
||||||
|
and policy_has_binding(
|
||||||
|
policy,
|
||||||
|
"roles/cloudsql.client",
|
||||||
|
runtime,
|
||||||
|
condition_title="observatory-exact-cloudsql-instance",
|
||||||
|
condition_expression=sql_expression,
|
||||||
|
)
|
||||||
|
and policy_has_binding(
|
||||||
|
policy,
|
||||||
|
"roles/cloudsql.instanceUser",
|
||||||
|
runtime,
|
||||||
|
condition_title="observatory-exact-cloudsql-instance",
|
||||||
|
condition_expression=sql_expression,
|
||||||
|
)
|
||||||
|
and policy_has_binding(policy, "roles/run.serviceAgent", service_agent)
|
||||||
|
and not (member_roles(policy, deployer) & forbidden_deployer)
|
||||||
|
and not (member_roles(policy, builder) & forbidden_builder)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def role_has_exact_permissions(value: str, expected: set[str]) -> bool:
|
||||||
|
payload = json.loads(value)
|
||||||
|
permissions = payload.get("includedPermissions", [])
|
||||||
|
return isinstance(permissions, list) and set(permissions) == expected and payload.get("deleted") is not True
|
||||||
|
|
||||||
|
|
||||||
|
def wif_provider_is_exact(value: str) -> bool:
|
||||||
|
payload = json.loads(value)
|
||||||
|
mapping = payload.get("attributeMapping", {})
|
||||||
|
expected_mapping = {
|
||||||
|
"google.subject": "assertion.sub",
|
||||||
|
"attribute.repository": "assertion.repository",
|
||||||
|
"attribute.ref": "assertion.ref",
|
||||||
|
"attribute.workflow_ref": "assertion.workflow_ref",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
payload.get("state") == "ACTIVE"
|
||||||
|
and mapping == expected_mapping
|
||||||
|
and normalized(str(payload.get("attributeCondition", ""))) == normalized(DEPLOY_WIF_ATTRIBUTE_CONDITION)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def deployer_service_account_policy_is_exact(value: str) -> bool:
|
||||||
|
policy = json.loads(value)
|
||||||
|
return policy_has_binding(policy, "roles/iam.workloadIdentityUser", DEPLOY_WIF_MEMBER)
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_service_account_policy_is_exact(value: str) -> bool:
|
||||||
|
policy = json.loads(value)
|
||||||
|
deployer = f"serviceAccount:{DEPLOY_SERVICE_ACCOUNT}"
|
||||||
|
builder = f"serviceAccount:{BUILD_SERVICE_ACCOUNT}"
|
||||||
|
return policy_has_binding(policy, "roles/iam.serviceAccountUser", deployer) and not member_roles(policy, builder)
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_policy_is_exact(value: str) -> bool:
|
||||||
|
policy = json.loads(value)
|
||||||
|
deployer = f"serviceAccount:{DEPLOY_SERVICE_ACCOUNT}"
|
||||||
|
return policy_has_binding(policy, "roles/artifactregistry.reader", deployer)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_policy_is_exact(value: str) -> bool:
|
||||||
|
policy = json.loads(value)
|
||||||
|
deployer = f"serviceAccount:{DEPLOY_SERVICE_ACCOUNT}"
|
||||||
|
runtime = f"serviceAccount:{RUNTIME_SERVICE_ACCOUNT}"
|
||||||
|
builder = f"serviceAccount:{BUILD_SERVICE_ACCOUNT}"
|
||||||
|
return (
|
||||||
|
policy_has_binding(policy, "roles/secretmanager.secretAccessor", deployer)
|
||||||
|
and policy_has_binding(policy, "roles/secretmanager.secretAccessor", runtime)
|
||||||
|
and policy_has_binding(policy, "roles/secretmanager.viewer", deployer)
|
||||||
|
and not member_roles(policy, builder)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def api_key_secret_check() -> Check:
|
||||||
|
version_result = run(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"secrets",
|
||||||
|
"versions",
|
||||||
|
"list",
|
||||||
|
SECRET,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--filter=state=ENABLED",
|
||||||
|
"--sort-by=~createTime",
|
||||||
|
"--limit=1",
|
||||||
|
"--format=value(name)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if version_result.returncode != 0:
|
||||||
|
return Check("api_key_secret", "blocked", sanitize_error(version_result.stderr))
|
||||||
|
version = version_result.stdout.strip().rsplit("/", 1)[-1]
|
||||||
|
if not version.isdigit():
|
||||||
|
return Check("api_key_secret", "blocked", "enabled_secret_version_missing")
|
||||||
|
secret_result = run(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"secrets",
|
||||||
|
"versions",
|
||||||
|
"access",
|
||||||
|
version,
|
||||||
|
"--secret",
|
||||||
|
SECRET,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if secret_result.returncode != 0:
|
||||||
|
return Check("api_key_secret", "blocked", sanitize_error(secret_result.stderr))
|
||||||
|
valid = secret_is_valid(secret_result.stdout)
|
||||||
|
detail = f"enabled_version_{version}_valid_length_value_redacted" if valid else "invalid_secret_value"
|
||||||
|
return Check("api_key_secret", "pass" if valid else "blocked", detail)
|
||||||
|
|
||||||
|
|
||||||
|
def existing_service_policy_check() -> Check:
|
||||||
|
describe = run(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"run",
|
||||||
|
"services",
|
||||||
|
"describe",
|
||||||
|
SERVICE,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--region",
|
||||||
|
REGION,
|
||||||
|
"--format=value(metadata.name)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if describe.returncode != 0:
|
||||||
|
error = sanitize_error(describe.stderr)
|
||||||
|
if "not found" in error.lower() or "not_found" in error.lower():
|
||||||
|
return Check("existing_service_policy", "pass", "service_not_created_yet")
|
||||||
|
return Check("existing_service_policy", "blocked", error)
|
||||||
|
if describe.stdout.strip() != SERVICE:
|
||||||
|
return Check("existing_service_policy", "blocked", "unexpected_service_readback")
|
||||||
|
policy_result = run(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"run",
|
||||||
|
"services",
|
||||||
|
"get-iam-policy",
|
||||||
|
SERVICE,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--region",
|
||||||
|
REGION,
|
||||||
|
"--format=json",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if policy_result.returncode != 0:
|
||||||
|
return Check("existing_service_policy", "blocked", sanitize_error(policy_result.stderr))
|
||||||
|
try:
|
||||||
|
policy = json.loads(policy_result.stdout)
|
||||||
|
builder = f"serviceAccount:{BUILD_SERVICE_ACCOUNT}"
|
||||||
|
valid = not member_roles(policy, builder)
|
||||||
|
except (AttributeError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
return Check("existing_service_policy", "blocked", f"invalid_readback:{type(exc).__name__}")
|
||||||
|
return Check(
|
||||||
|
"existing_service_policy",
|
||||||
|
"pass" if valid else "blocked",
|
||||||
|
"artifact_builder_absent" if valid else "artifact_builder_has_service_binding",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_checks(image_ref: str) -> list[Check]:
|
||||||
|
image_name, image_digest = image_ref.rsplit("@", 1) if "@" in image_ref else (image_ref, "")
|
||||||
|
digest_ref = f"{image_name.rsplit(':', 1)[0]}@{image_digest}" if image_digest else image_ref
|
||||||
|
checks = [
|
||||||
|
command_check(
|
||||||
|
"active_identity",
|
||||||
|
["gcloud", "auth", "list", "--filter=status:ACTIVE", "--format=value(account)"],
|
||||||
|
lambda value: value.strip() == DEPLOY_SERVICE_ACCOUNT,
|
||||||
|
"dedicated_observatory_deployer",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"project",
|
||||||
|
["gcloud", "projects", "describe", PROJECT, "--format=value(projectId)"],
|
||||||
|
lambda value: value.strip() == PROJECT,
|
||||||
|
PROJECT,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
checks.extend(
|
||||||
|
command_check(
|
||||||
|
f"api_{api.removesuffix('.googleapis.com')}",
|
||||||
|
["gcloud", "services", "describe", api, "--project", PROJECT, "--format=value(state)"],
|
||||||
|
lambda value: value.strip() == "ENABLED",
|
||||||
|
"enabled",
|
||||||
|
)
|
||||||
|
for api in REQUIRED_APIS
|
||||||
|
)
|
||||||
|
checks.extend(
|
||||||
|
[
|
||||||
|
command_check(
|
||||||
|
"deployer_wif_provider",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"workload-identity-pools",
|
||||||
|
"providers",
|
||||||
|
"describe",
|
||||||
|
DEPLOY_WIF_PROVIDER,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--location",
|
||||||
|
"global",
|
||||||
|
"--workload-identity-pool",
|
||||||
|
WIF_POOL,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
wif_provider_is_exact,
|
||||||
|
"main_and_exact_workflow_only",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"preflight_custom_role",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"roles",
|
||||||
|
"describe",
|
||||||
|
"observatoryStagingPreflight",
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
lambda value: role_has_exact_permissions(value, PREFLIGHT_PERMISSIONS),
|
||||||
|
"exact_read_only_permissions",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"deploy_custom_role",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"roles",
|
||||||
|
"describe",
|
||||||
|
"observatoryStagingDeployer",
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
lambda value: role_has_exact_permissions(value, DEPLOY_PERMISSIONS),
|
||||||
|
"five_permissions_no_delete",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"project_iam",
|
||||||
|
["gcloud", "projects", "get-iam-policy", PROJECT, "--format=json"],
|
||||||
|
project_iam_is_exact,
|
||||||
|
"required_bindings_present_broad_roles_absent",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"deployer_wif_binding",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"service-accounts",
|
||||||
|
"get-iam-policy",
|
||||||
|
DEPLOY_SERVICE_ACCOUNT,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
deployer_service_account_policy_is_exact,
|
||||||
|
"exact_main_subject_workload_identity_user",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"runtime_act_as_policy",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"service-accounts",
|
||||||
|
"get-iam-policy",
|
||||||
|
RUNTIME_SERVICE_ACCOUNT,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
runtime_service_account_policy_is_exact,
|
||||||
|
"deployer_only_artifact_builder_absent",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"artifact_repository_policy",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"artifacts",
|
||||||
|
"repositories",
|
||||||
|
"get-iam-policy",
|
||||||
|
REPOSITORY,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--location",
|
||||||
|
REGION,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
artifact_policy_is_exact,
|
||||||
|
"deployer_reader",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"secret_policy",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"secrets",
|
||||||
|
"get-iam-policy",
|
||||||
|
SECRET,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
secret_policy_is_exact,
|
||||||
|
"runtime_and_deployer_only_artifact_builder_absent",
|
||||||
|
),
|
||||||
|
existing_service_policy_check(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
checks.extend(
|
||||||
|
[
|
||||||
|
command_check(
|
||||||
|
"runtime_service_account",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"service-accounts",
|
||||||
|
"describe",
|
||||||
|
RUNTIME_SERVICE_ACCOUNT,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=value(email)",
|
||||||
|
],
|
||||||
|
lambda value: value.strip() == RUNTIME_SERVICE_ACCOUNT,
|
||||||
|
RUNTIME_SERVICE_ACCOUNT,
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"vpc_network",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"compute",
|
||||||
|
"networks",
|
||||||
|
"describe",
|
||||||
|
NETWORK,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=value(name)",
|
||||||
|
],
|
||||||
|
lambda value: value.strip() == NETWORK,
|
||||||
|
NETWORK,
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"vpc_subnet",
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"compute",
|
||||||
|
"networks",
|
||||||
|
"subnets",
|
||||||
|
"describe",
|
||||||
|
SUBNET,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--region",
|
||||||
|
REGION,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
subnet_is_exact,
|
||||||
|
f"{NETWORK}/{REGION}/{SUBNET}",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"cloud_sql_private_iam",
|
||||||
|
["gcloud", "sql", "instances", "describe", INSTANCE, "--project", PROJECT, "--format=json"],
|
||||||
|
cloud_sql_is_private_and_iam_enabled,
|
||||||
|
f"{PROJECT}:{REGION}:{INSTANCE}:private_ip_only:iam_authentication_on",
|
||||||
|
),
|
||||||
|
command_check(
|
||||||
|
"cloud_sql_iam_user",
|
||||||
|
["gcloud", "sql", "users", "list", "--instance", INSTANCE, "--project", PROJECT, "--format=json"],
|
||||||
|
cloud_sql_iam_user_exists,
|
||||||
|
DB_IAM_USER,
|
||||||
|
),
|
||||||
|
api_key_secret_check(),
|
||||||
|
command_check(
|
||||||
|
"immutable_image",
|
||||||
|
["gcloud", "artifacts", "docker", "images", "describe", digest_ref, "--project", PROJECT, "--format=json"],
|
||||||
|
lambda _value: bool(IMAGE_RE.fullmatch(image_ref)),
|
||||||
|
image_digest or "missing_digest",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--image-ref", required=True)
|
||||||
|
parser.add_argument("--output", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
checks = build_checks(args.image_ref)
|
||||||
|
pass_count = sum(check.status == "pass" for check in checks)
|
||||||
|
blocked_count = len(checks) - pass_count
|
||||||
|
receipt = {
|
||||||
|
"schema": "livingip.observatory-read-adapter-gcp-preflight.v1",
|
||||||
|
"project": PROJECT,
|
||||||
|
"required_tier": "T3_live_readonly",
|
||||||
|
"current_tier": "T2_authenticated_gcp_preflight",
|
||||||
|
"ready_for_staging_deploy": blocked_count == 0,
|
||||||
|
"claim_ceiling": "Preflight only; T3 requires the live private Cloud SQL claim and negative receipts.",
|
||||||
|
"identity": DEPLOY_SERVICE_ACCOUNT,
|
||||||
|
"checks": [asdict(check) for check in checks],
|
||||||
|
"pass_count": pass_count,
|
||||||
|
"blocked_count": blocked_count,
|
||||||
|
"secret_values_retained": False,
|
||||||
|
"database_role_live_verification_deferred_to_fail_closed_canary": True,
|
||||||
|
"production_repoint_executed": False,
|
||||||
|
}
|
||||||
|
rendered = json.dumps(receipt, indent=2, sort_keys=True) + "\n"
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(rendered, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
print(rendered, end="")
|
||||||
|
return 0 if blocked_count == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
475
ops/plan_observatory_read_adapter_gcp.py
Normal file
475
ops/plan_observatory_read_adapter_gcp.py
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Emit the unexecuted least-privilege GCP staging packet for Observatory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
PROJECT = "teleo-501523"
|
||||||
|
PROJECT_NUMBER = "785938879453"
|
||||||
|
REGION = "europe-west6"
|
||||||
|
GITHUB_REPOSITORY = "living-ip/teleo-infrastructure"
|
||||||
|
WIF_POOL = "github-actions"
|
||||||
|
DEPLOY_WIF_PROVIDER = "observatory-read-adapter-main"
|
||||||
|
DEPLOY_WORKFLOW_REF = (
|
||||||
|
f"{GITHUB_REPOSITORY}/.github/workflows/gcp-observatory-read-adapter.yml@refs/heads/main"
|
||||||
|
)
|
||||||
|
DEPLOY_WIF_ATTRIBUTE_MAPPING = (
|
||||||
|
"google.subject=assertion.sub,attribute.repository=assertion.repository,"
|
||||||
|
"attribute.ref=assertion.ref,attribute.workflow_ref=assertion.workflow_ref"
|
||||||
|
)
|
||||||
|
DEPLOY_WIF_ATTRIBUTE_CONDITION = (
|
||||||
|
"assertion.repository=='living-ip/teleo-infrastructure' && "
|
||||||
|
"assertion.ref=='refs/heads/main' && "
|
||||||
|
f"assertion.workflow_ref=='{DEPLOY_WORKFLOW_REF}'"
|
||||||
|
)
|
||||||
|
BUILD_SERVICE_ACCOUNT = f"sa-artifact-builder@{PROJECT}.iam.gserviceaccount.com"
|
||||||
|
DEPLOY_SERVICE_ACCOUNT_ID = "sa-observatory-deployer"
|
||||||
|
DEPLOY_SERVICE_ACCOUNT = f"{DEPLOY_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
|
||||||
|
RUNTIME_SERVICE_ACCOUNT_ID = "sa-observatory-read-adapter"
|
||||||
|
RUNTIME_SERVICE_ACCOUNT = f"{RUNTIME_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
|
||||||
|
DB_IAM_USER = f"{RUNTIME_SERVICE_ACCOUNT_ID}@{PROJECT}.iam"
|
||||||
|
SERVICE = "observatory-read-adapter-staging"
|
||||||
|
INSTANCE = "teleo-pgvector-standby"
|
||||||
|
NETWORK = "teleo-staging-net"
|
||||||
|
SUBNET = "teleo-staging-europe-west6"
|
||||||
|
SECRET = "observatory-read-api-key-staging"
|
||||||
|
REPOSITORY = "teleo"
|
||||||
|
PREFLIGHT_ROLE_ID = "observatoryStagingPreflight"
|
||||||
|
PREFLIGHT_ROLE = f"projects/{PROJECT}/roles/{PREFLIGHT_ROLE_ID}"
|
||||||
|
DEPLOY_ROLE_ID = "observatoryStagingDeployer"
|
||||||
|
DEPLOY_ROLE = f"projects/{PROJECT}/roles/{DEPLOY_ROLE_ID}"
|
||||||
|
PREFLIGHT_PERMISSIONS = (
|
||||||
|
"artifactregistry.repositories.getIamPolicy",
|
||||||
|
"cloudsql.instances.get",
|
||||||
|
"cloudsql.users.list",
|
||||||
|
"compute.networks.get",
|
||||||
|
"compute.subnetworks.get",
|
||||||
|
"iam.serviceAccounts.get",
|
||||||
|
"iam.serviceAccounts.getIamPolicy",
|
||||||
|
"iam.roles.get",
|
||||||
|
"iam.workloadIdentityPoolProviders.get",
|
||||||
|
"resourcemanager.projects.get",
|
||||||
|
"resourcemanager.projects.getIamPolicy",
|
||||||
|
"run.operations.get",
|
||||||
|
"run.services.get",
|
||||||
|
"run.services.getIamPolicy",
|
||||||
|
"secretmanager.secrets.getIamPolicy",
|
||||||
|
"serviceusage.services.get",
|
||||||
|
"serviceusage.services.use",
|
||||||
|
)
|
||||||
|
DEPLOY_PERMISSIONS = (
|
||||||
|
"run.operations.get",
|
||||||
|
"run.services.create",
|
||||||
|
"run.services.get",
|
||||||
|
"run.services.setIamPolicy",
|
||||||
|
"run.services.update",
|
||||||
|
)
|
||||||
|
REQUIRED_APIS = (
|
||||||
|
"artifactregistry.googleapis.com",
|
||||||
|
"compute.googleapis.com",
|
||||||
|
"iamcredentials.googleapis.com",
|
||||||
|
"run.googleapis.com",
|
||||||
|
"secretmanager.googleapis.com",
|
||||||
|
"sqladmin.googleapis.com",
|
||||||
|
"sts.googleapis.com",
|
||||||
|
)
|
||||||
|
FORBIDDEN_DEPLOYER_ROLES = (
|
||||||
|
"roles/artifactregistry.writer",
|
||||||
|
"roles/cloudsql.admin",
|
||||||
|
"roles/compute.admin",
|
||||||
|
"roles/editor",
|
||||||
|
"roles/owner",
|
||||||
|
"roles/run.admin",
|
||||||
|
"roles/run.developer",
|
||||||
|
"roles/secretmanager.admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def shell_join(parts: list[str]) -> str:
|
||||||
|
return " ".join(shlex.quote(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def github_wif_member() -> str:
|
||||||
|
return (
|
||||||
|
f"principal://iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/"
|
||||||
|
f"workloadIdentityPools/{WIF_POOL}/subject/repo:{GITHUB_REPOSITORY}:ref:refs/heads/main"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def service_account_member(account: str) -> str:
|
||||||
|
return f"serviceAccount:{account}"
|
||||||
|
|
||||||
|
|
||||||
|
def project_binding(member: str, role: str, condition: str | None = None) -> str:
|
||||||
|
command = [
|
||||||
|
"gcloud",
|
||||||
|
"projects",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
PROJECT,
|
||||||
|
"--member",
|
||||||
|
member,
|
||||||
|
"--role",
|
||||||
|
role,
|
||||||
|
]
|
||||||
|
if condition:
|
||||||
|
command.extend(["--condition", condition])
|
||||||
|
return shell_join(command)
|
||||||
|
|
||||||
|
|
||||||
|
def custom_role_commands(
|
||||||
|
role_id: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
permissions: tuple[str, ...],
|
||||||
|
) -> list[str]:
|
||||||
|
common = [
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--title",
|
||||||
|
title,
|
||||||
|
"--description",
|
||||||
|
description,
|
||||||
|
"--permissions",
|
||||||
|
",".join(permissions),
|
||||||
|
"--stage",
|
||||||
|
"GA",
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
f"gcloud iam roles describe {shlex.quote(role_id)} --project {shlex.quote(PROJECT)} "
|
||||||
|
"|| "
|
||||||
|
+ shell_join(["gcloud", "iam", "roles", "create", role_id, *common])
|
||||||
|
),
|
||||||
|
shell_join(["gcloud", "iam", "roles", "update", role_id, *common]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan() -> dict[str, object]:
|
||||||
|
deployer_member = service_account_member(DEPLOY_SERVICE_ACCOUNT)
|
||||||
|
runtime_member = service_account_member(RUNTIME_SERVICE_ACCOUNT)
|
||||||
|
cloud_sql_condition = (
|
||||||
|
"expression=resource.name == "
|
||||||
|
f"'projects/{PROJECT}/instances/{INSTANCE}' && resource.service == 'sqladmin.googleapis.com',"
|
||||||
|
"title=observatory-exact-cloudsql-instance"
|
||||||
|
)
|
||||||
|
create_accounts = [
|
||||||
|
(
|
||||||
|
DEPLOY_SERVICE_ACCOUNT,
|
||||||
|
DEPLOY_SERVICE_ACCOUNT_ID,
|
||||||
|
"Observatory staging deploy and canary",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RUNTIME_SERVICE_ACCOUNT,
|
||||||
|
RUNTIME_SERVICE_ACCOUNT_ID,
|
||||||
|
"Observatory private Cloud SQL reader",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
commands = [
|
||||||
|
shell_join(["gcloud", "projects", "describe", PROJECT, "--format=value(projectId)"]),
|
||||||
|
shell_join(["gcloud", "services", "enable", *REQUIRED_APIS, "--project", PROJECT]),
|
||||||
|
project_binding(
|
||||||
|
f"serviceAccount:service-{PROJECT_NUMBER}@serverless-robot-prod.iam.gserviceaccount.com",
|
||||||
|
"roles/run.serviceAgent",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for email, account_id, display_name in create_accounts:
|
||||||
|
commands.append(
|
||||||
|
f"gcloud iam service-accounts describe {shlex.quote(email)} --project {shlex.quote(PROJECT)} "
|
||||||
|
f"|| {shell_join(['gcloud', 'iam', 'service-accounts', 'create', account_id, '--project', PROJECT, '--display-name', display_name])}"
|
||||||
|
)
|
||||||
|
commands.extend(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"gcloud iam workload-identity-pools providers describe "
|
||||||
|
+ shlex.quote(DEPLOY_WIF_PROVIDER)
|
||||||
|
+ " --project "
|
||||||
|
+ shlex.quote(PROJECT)
|
||||||
|
+ " --location global --workload-identity-pool "
|
||||||
|
+ shlex.quote(WIF_POOL)
|
||||||
|
+ " || "
|
||||||
|
+ shell_join(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"workload-identity-pools",
|
||||||
|
"providers",
|
||||||
|
"create-oidc",
|
||||||
|
DEPLOY_WIF_PROVIDER,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--location",
|
||||||
|
"global",
|
||||||
|
"--workload-identity-pool",
|
||||||
|
WIF_POOL,
|
||||||
|
"--issuer-uri",
|
||||||
|
"https://token.actions.githubusercontent.com/",
|
||||||
|
"--attribute-mapping",
|
||||||
|
DEPLOY_WIF_ATTRIBUTE_MAPPING,
|
||||||
|
"--attribute-condition",
|
||||||
|
DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
shell_join(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"workload-identity-pools",
|
||||||
|
"providers",
|
||||||
|
"update-oidc",
|
||||||
|
DEPLOY_WIF_PROVIDER,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--location",
|
||||||
|
"global",
|
||||||
|
"--workload-identity-pool",
|
||||||
|
WIF_POOL,
|
||||||
|
"--attribute-mapping",
|
||||||
|
DEPLOY_WIF_ATTRIBUTE_MAPPING,
|
||||||
|
"--attribute-condition",
|
||||||
|
DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
shell_join(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"service-accounts",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
DEPLOY_SERVICE_ACCOUNT,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--member",
|
||||||
|
github_wif_member(),
|
||||||
|
"--role",
|
||||||
|
"roles/iam.workloadIdentityUser",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
commands.extend(
|
||||||
|
custom_role_commands(
|
||||||
|
PREFLIGHT_ROLE_ID,
|
||||||
|
"Observatory staging preflight",
|
||||||
|
"Read only the exact resource classes required before the private staging deploy",
|
||||||
|
PREFLIGHT_PERMISSIONS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
commands.extend(
|
||||||
|
custom_role_commands(
|
||||||
|
DEPLOY_ROLE_ID,
|
||||||
|
"Observatory staging deployer",
|
||||||
|
"Create or update Cloud Run without delete or broad infrastructure permissions",
|
||||||
|
DEPLOY_PERMISSIONS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
commands.extend(
|
||||||
|
[
|
||||||
|
project_binding(deployer_member, PREFLIGHT_ROLE),
|
||||||
|
project_binding(deployer_member, DEPLOY_ROLE),
|
||||||
|
shell_join(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"artifacts",
|
||||||
|
"repositories",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
REPOSITORY,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--location",
|
||||||
|
REGION,
|
||||||
|
"--member",
|
||||||
|
deployer_member,
|
||||||
|
"--role",
|
||||||
|
"roles/artifactregistry.reader",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
shell_join(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"service-accounts",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
RUNTIME_SERVICE_ACCOUNT,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--member",
|
||||||
|
deployer_member,
|
||||||
|
"--role",
|
||||||
|
"roles/iam.serviceAccountUser",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
project_binding(runtime_member, "roles/cloudsql.client", cloud_sql_condition),
|
||||||
|
project_binding(runtime_member, "roles/cloudsql.instanceUser", cloud_sql_condition),
|
||||||
|
f"gcloud secrets describe {shlex.quote(SECRET)} --project {shlex.quote(PROJECT)} "
|
||||||
|
f"|| {shell_join(['gcloud', 'secrets', 'create', SECRET, '--project', PROJECT, '--replication-policy', 'automatic'])}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for member in (runtime_member, deployer_member):
|
||||||
|
commands.append(
|
||||||
|
shell_join(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"secrets",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
SECRET,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--member",
|
||||||
|
member,
|
||||||
|
"--role",
|
||||||
|
"roles/secretmanager.secretAccessor",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
commands.extend(
|
||||||
|
[
|
||||||
|
shell_join(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"secrets",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
SECRET,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--member",
|
||||||
|
deployer_member,
|
||||||
|
"--role",
|
||||||
|
"roles/secretmanager.viewer",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"if ! gcloud secrets versions list "
|
||||||
|
+ shlex.quote(SECRET)
|
||||||
|
+ " --project "
|
||||||
|
+ shlex.quote(PROJECT)
|
||||||
|
+ " --filter='state=ENABLED' --limit=1 --format='value(name)' | grep -q .; then "
|
||||||
|
+ "openssl rand -hex 32 | tr -d '\\n' | gcloud secrets versions add "
|
||||||
|
+ shlex.quote(SECRET)
|
||||||
|
+ " --project "
|
||||||
|
+ shlex.quote(PROJECT)
|
||||||
|
+ " --data-file=-; fi"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"if ! gcloud sql users list --instance "
|
||||||
|
+ shlex.quote(INSTANCE)
|
||||||
|
+ " --project "
|
||||||
|
+ shlex.quote(PROJECT)
|
||||||
|
+ " --filter='type=CLOUD_IAM_SERVICE_ACCOUNT' --format='value(name)' | grep -Fxq "
|
||||||
|
+ shlex.quote(DB_IAM_USER)
|
||||||
|
+ "; then "
|
||||||
|
+ shell_join(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"sql",
|
||||||
|
"users",
|
||||||
|
"create",
|
||||||
|
DB_IAM_USER,
|
||||||
|
"--instance",
|
||||||
|
INSTANCE,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--type",
|
||||||
|
"CLOUD_IAM_SERVICE_ACCOUNT",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "; fi"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": "livingip.observatory-read-adapter-gcp-plan.v1",
|
||||||
|
"project": PROJECT,
|
||||||
|
"required_tier": "T3_live_readonly",
|
||||||
|
"current_tier": "T2_unexecuted_least_privilege_packet",
|
||||||
|
"claim_ceiling": "Plan only; no IAM, API, secret, Cloud SQL, Cloud Run, or routing mutation has run.",
|
||||||
|
"identities": {
|
||||||
|
"build": BUILD_SERVICE_ACCOUNT,
|
||||||
|
"deploy_and_canary": DEPLOY_SERVICE_ACCOUNT,
|
||||||
|
"runtime": RUNTIME_SERVICE_ACCOUNT,
|
||||||
|
"database_principal": DB_IAM_USER,
|
||||||
|
},
|
||||||
|
"kept_narrow": {
|
||||||
|
"artifact_builder": "image build and push only; never deploy or receive broad infra roles",
|
||||||
|
"deployer": "workflow-specific main-only WIF plus a five-permission Cloud Run role, repository read, exact runtime actAs, exact secret access",
|
||||||
|
"runtime": "exact Cloud SQL instance connect/login plus exact secret access",
|
||||||
|
},
|
||||||
|
"github_wif_provider": (
|
||||||
|
f"projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{WIF_POOL}/providers/{DEPLOY_WIF_PROVIDER}"
|
||||||
|
),
|
||||||
|
"github_wif_member": github_wif_member(),
|
||||||
|
"required_apis": list(REQUIRED_APIS),
|
||||||
|
"preflight_custom_role": {
|
||||||
|
"name": PREFLIGHT_ROLE,
|
||||||
|
"permissions": list(PREFLIGHT_PERMISSIONS),
|
||||||
|
},
|
||||||
|
"deploy_custom_role": {
|
||||||
|
"name": DEPLOY_ROLE,
|
||||||
|
"permissions": list(DEPLOY_PERMISSIONS),
|
||||||
|
},
|
||||||
|
"conditions": {
|
||||||
|
"cloud_sql": cloud_sql_condition,
|
||||||
|
"deployer_wif": DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
||||||
|
},
|
||||||
|
"commands": commands,
|
||||||
|
"private_database_action": {
|
||||||
|
"location": "existing authenticated private GCP VM database-admin path",
|
||||||
|
"command": (
|
||||||
|
f"OBSERVATORY_DB_IAM_USER={DB_IAM_USER} psql --dbname=teleo_canonical "
|
||||||
|
"--set=ON_ERROR_STOP=1 --file=ops/observatory_read_role.sql"
|
||||||
|
),
|
||||||
|
"required_receipt": (
|
||||||
|
"database=teleo_canonical, authorization_role=kb_observatory_read, exact database principal, "
|
||||||
|
"required_reads=true, effective_table_writes_denied=true"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"cloud_run_service_agent_check": {
|
||||||
|
"principal": f"service-{PROJECT_NUMBER}@serverless-robot-prod.iam.gserviceaccount.com",
|
||||||
|
"required_role": "roles/run.serviceAgent",
|
||||||
|
"reason": "Default Cloud Run service-agent role carries Direct VPC permissions; do not grant them to the runtime identity.",
|
||||||
|
"empty_readback_action": "Stop. Repair the Google-managed service-agent grant as an administrator before preflight.",
|
||||||
|
},
|
||||||
|
"post_apply_checks": [
|
||||||
|
f"gcloud projects describe {PROJECT} --format=value(projectId)",
|
||||||
|
"python3 ops/check_gcp_infra_readiness.py",
|
||||||
|
"Require the adapter preflight to verify the dedicated WIF provider, custom roles, IAM bindings, private Cloud SQL, secret, and immutable image.",
|
||||||
|
(
|
||||||
|
"gh workflow run gcp-observatory-read-adapter.yml --repo "
|
||||||
|
f"{GITHUB_REPOSITORY} --ref main -f action=preflight_staging"
|
||||||
|
),
|
||||||
|
"Require a passing observatory-read-adapter-preflight artifact before action=deploy_staging.",
|
||||||
|
],
|
||||||
|
"forbidden_deployer_roles": list(FORBIDDEN_DEPLOYER_ROLES),
|
||||||
|
"forbidden_actions": [
|
||||||
|
"grant deploy or infra roles to sa-artifact-builder",
|
||||||
|
"reuse a pre-existing image tag instead of building the current workflow revision",
|
||||||
|
"add a public Cloud SQL address",
|
||||||
|
"expose a secret value in Git, logs, or a receipt",
|
||||||
|
"add a write route",
|
||||||
|
"change Vercel or production routing",
|
||||||
|
],
|
||||||
|
"production_repoint_executed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--format", choices=("json", "shell"), default="json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
plan = build_plan()
|
||||||
|
if args.format == "shell":
|
||||||
|
print("# Unexecuted staging packet. Review and run only as an authenticated teleo-501523 administrator.")
|
||||||
|
print("set -euo pipefail")
|
||||||
|
for command in plan["commands"]:
|
||||||
|
print(command)
|
||||||
|
else:
|
||||||
|
print(json.dumps(plan, indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
29
ops/redact_observatory_read_adapter_receipt.jq
Normal file
29
ops/redact_observatory_read_adapter_receipt.jq
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
schema,
|
||||||
|
read_only,
|
||||||
|
provenance: {
|
||||||
|
gcp_project: .provenance.gcp_project,
|
||||||
|
cloud_sql_instance: .provenance.cloud_sql_instance,
|
||||||
|
database: .provenance.database,
|
||||||
|
database_principal: .provenance.database_principal,
|
||||||
|
authorization_role: .provenance.authorization_role,
|
||||||
|
transaction_read_only: .provenance.transaction_read_only,
|
||||||
|
write_privileges_denied: .provenance.write_privileges_denied,
|
||||||
|
service_revision: .provenance.service_revision
|
||||||
|
},
|
||||||
|
canonical: {
|
||||||
|
claim: {
|
||||||
|
id: .canonical.claim.id,
|
||||||
|
type: .canonical.claim.type,
|
||||||
|
status: .canonical.claim.status
|
||||||
|
},
|
||||||
|
evidence_count: (.canonical.evidence | length),
|
||||||
|
incoming_edge_count: ([.canonical.edges[] | select(.direction == "incoming")] | length),
|
||||||
|
outgoing_edge_count: ([.canonical.edges[] | select(.direction == "outgoing")] | length)
|
||||||
|
},
|
||||||
|
proposal_ledger: {
|
||||||
|
distinct_from_canonical: .proposal_ledger.distinct_from_canonical,
|
||||||
|
status_counts: .proposal_ledger.status_counts,
|
||||||
|
related_count: (.proposal_ledger.related | length)
|
||||||
|
}
|
||||||
|
}
|
||||||
383
tests/test_observatory_read_adapter_gcp_plan.py
Normal file
383
tests/test_observatory_read_adapter_gcp_plan.py
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from ops import check_observatory_read_adapter_gcp_preflight as preflight
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run_plan(*args: str) -> str:
|
||||||
|
return subprocess.check_output(
|
||||||
|
["python3", "ops/plan_observatory_read_adapter_gcp.py", *args],
|
||||||
|
cwd=ROOT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_splits_build_deploy_and_runtime_identities() -> None:
|
||||||
|
plan = json.loads(run_plan())
|
||||||
|
|
||||||
|
assert plan["current_tier"] == "T2_unexecuted_least_privilege_packet"
|
||||||
|
assert plan["identities"] == {
|
||||||
|
"build": "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com",
|
||||||
|
"deploy_and_canary": "sa-observatory-deployer@teleo-501523.iam.gserviceaccount.com",
|
||||||
|
"runtime": "sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com",
|
||||||
|
"database_principal": "sa-observatory-read-adapter@teleo-501523.iam",
|
||||||
|
}
|
||||||
|
commands = "\n".join(plan["commands"])
|
||||||
|
assert "roles/iam.workloadIdentityUser" in commands
|
||||||
|
assert "roles/iam.serviceAccountUser" in commands
|
||||||
|
assert "roles/cloudsql.client" in commands
|
||||||
|
assert "roles/cloudsql.instanceUser" in commands
|
||||||
|
assert "roles/artifactregistry.reader" in commands
|
||||||
|
assert "roles/secretmanager.secretAccessor" in commands
|
||||||
|
assert "subject/repo:living-ip/teleo-infrastructure:ref:refs/heads/main" in plan["github_wif_member"]
|
||||||
|
assert plan["conditions"]["deployer_wif"].endswith(
|
||||||
|
"assertion.workflow_ref=='living-ip/teleo-infrastructure/.github/workflows/"
|
||||||
|
"gcp-observatory-read-adapter.yml@refs/heads/main'"
|
||||||
|
)
|
||||||
|
assert plan["github_wif_provider"].endswith("/providers/observatory-read-adapter-main")
|
||||||
|
assert "roles/run.serviceAgent" in commands
|
||||||
|
assert "teleo-pgvector-standby" in plan["conditions"]["cloud_sql"]
|
||||||
|
assert set(plan["deploy_custom_role"]["permissions"]) == {
|
||||||
|
"run.operations.get",
|
||||||
|
"run.services.create",
|
||||||
|
"run.services.get",
|
||||||
|
"run.services.setIamPolicy",
|
||||||
|
"run.services.update",
|
||||||
|
}
|
||||||
|
assert "roles/run.admin" not in commands
|
||||||
|
assert "roles/run.developer" not in commands
|
||||||
|
assert "sa-artifact-builder" not in commands
|
||||||
|
assert not set(plan["forbidden_deployer_roles"]) & set(commands.split())
|
||||||
|
assert plan["production_repoint_executed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_packet_is_unexecuted_and_preserves_private_boundaries() -> None:
|
||||||
|
shell = run_plan("--format", "shell")
|
||||||
|
|
||||||
|
assert shell.startswith("# Unexecuted staging packet")
|
||||||
|
assert "set -euo pipefail" in shell
|
||||||
|
assert "run.googleapis.com" in shell
|
||||||
|
assert "--type CLOUD_IAM_SERVICE_ACCOUNT" in shell
|
||||||
|
assert "openssl rand -hex 32 | tr -d '\\n'" in shell
|
||||||
|
assert "--data-file=-" in shell
|
||||||
|
assert "grep -Fxq sa-observatory-read-adapter@teleo-501523.iam" in shell
|
||||||
|
assert "users create sa-observatory-read-adapter@teleo-501523.iam" in shell
|
||||||
|
assert "add-iam-policy-binding teleo" in shell
|
||||||
|
assert "roles/artifactregistry.writer" not in shell
|
||||||
|
assert "roles/cloudsql.admin" not in shell
|
||||||
|
assert "Vercel" not in shell
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_requires_dedicated_preflight_before_staging_deploy() -> None:
|
||||||
|
workflow_path = ROOT / ".github" / "workflows" / "gcp-observatory-read-adapter.yml"
|
||||||
|
workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
|
||||||
|
text = workflow_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert workflow["env"]["BUILD_SERVICE_ACCOUNT"].startswith("sa-artifact-builder@")
|
||||||
|
assert workflow["env"]["DEPLOY_SERVICE_ACCOUNT"].startswith("sa-observatory-deployer@")
|
||||||
|
assert workflow["env"]["DEPLOY_WORKLOAD_IDENTITY_PROVIDER"].endswith(
|
||||||
|
"/providers/observatory-read-adapter-main"
|
||||||
|
)
|
||||||
|
assert workflow["jobs"]["deploy-staging"]["needs"] == ["build", "preflight-staging"]
|
||||||
|
assert "action=preflight_staging" not in text
|
||||||
|
assert "inputs.action != 'build_only'" in text
|
||||||
|
assert "EXPECTED_WORKFLOW_REF" in text
|
||||||
|
assert "${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" in text
|
||||||
|
assert "docker images describe" in text
|
||||||
|
assert "2>/dev/null || true" not in text
|
||||||
|
assert "--no-invoker-iam-check" in text
|
||||||
|
assert "--allow-unauthenticated" not in text
|
||||||
|
assert "API_KEY_VERSION" in text
|
||||||
|
assert "EXPECTED_IMAGE_REF" in text
|
||||||
|
assert "check_observatory_read_adapter_gcp_preflight.py" in text
|
||||||
|
assert 'service_account: ${{ env.BUILD_SERVICE_ACCOUNT }}' in text
|
||||||
|
assert text.count('service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }}') == 2
|
||||||
|
assert "positive-redacted.json" in text
|
||||||
|
assert "redact_observatory_read_adapter_receipt.jq" in text
|
||||||
|
assert "database_principal == $db_iam_user" in text
|
||||||
|
assert "canonical_state_unchanged_after_post: true" in text
|
||||||
|
assert "cmp <(jq -S . positive.json) <(jq -S . after-write-attempt.json)" in text
|
||||||
|
assert "rm -f positive.json" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_private_cloud_sql_and_secret_validators() -> None:
|
||||||
|
instance = {
|
||||||
|
"name": "teleo-pgvector-standby",
|
||||||
|
"region": "europe-west6",
|
||||||
|
"databaseVersion": "POSTGRES_16",
|
||||||
|
"settings": {
|
||||||
|
"databaseFlags": [{"name": "cloudsql.iam_authentication", "value": "on"}],
|
||||||
|
"ipConfiguration": {
|
||||||
|
"ipv4Enabled": False,
|
||||||
|
"privateNetwork": "projects/teleo-501523/global/networks/teleo-staging-net",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ipAddresses": [{"type": "PRIVATE", "ipAddress": "10.61.0.3"}],
|
||||||
|
}
|
||||||
|
assert preflight.cloud_sql_is_private_and_iam_enabled(json.dumps(instance)) is True
|
||||||
|
|
||||||
|
instance["settings"]["ipConfiguration"]["ipv4Enabled"] = True
|
||||||
|
instance["ipAddresses"].append({"type": "PRIMARY", "ipAddress": "203.0.113.1"})
|
||||||
|
assert preflight.cloud_sql_is_private_and_iam_enabled(json.dumps(instance)) is False
|
||||||
|
assert preflight.cloud_sql_iam_user_exists(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "sa-observatory-read-adapter@teleo-501523.iam",
|
||||||
|
"type": "CLOUD_IAM_SERVICE_ACCOUNT",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert preflight.subnet_is_exact(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "teleo-staging-europe-west6",
|
||||||
|
"region": "https://www.googleapis.com/compute/v1/projects/teleo-501523/regions/europe-west6",
|
||||||
|
"network": "https://www.googleapis.com/compute/v1/projects/teleo-501523/global/networks/teleo-staging-net",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert preflight.secret_is_valid("a" * 32) is True
|
||||||
|
assert preflight.secret_is_valid("too-short") is False
|
||||||
|
assert preflight.secret_is_valid("a" * 32 + "\n") is False
|
||||||
|
assert preflight.secret_is_valid(" " + "a" * 32) is False
|
||||||
|
assert "gha-creds" not in preflight.sanitize_error("/tmp/work/gha-creds-deadbeef.json denied")
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_end_to_end_snapshot_retains_no_secret(monkeypatch) -> None:
|
||||||
|
instance = {
|
||||||
|
"name": "teleo-pgvector-standby",
|
||||||
|
"region": "europe-west6",
|
||||||
|
"databaseVersion": "POSTGRES_16",
|
||||||
|
"settings": {
|
||||||
|
"databaseFlags": [{"name": "cloudsql.iam_authentication", "value": "on"}],
|
||||||
|
"ipConfiguration": {
|
||||||
|
"ipv4Enabled": False,
|
||||||
|
"privateNetwork": "projects/teleo-501523/global/networks/teleo-staging-net",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ipAddresses": [{"type": "PRIVATE", "ipAddress": "10.61.0.3"}],
|
||||||
|
}
|
||||||
|
secret_value = "never-retain-this-secret-value-000000000000"
|
||||||
|
deployer = "serviceAccount:sa-observatory-deployer@teleo-501523.iam.gserviceaccount.com"
|
||||||
|
runtime = "serviceAccount:sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com"
|
||||||
|
project_policy = {
|
||||||
|
"bindings": [
|
||||||
|
{"role": preflight.PREFLIGHT_ROLE, "members": [deployer]},
|
||||||
|
{"role": preflight.DEPLOY_ROLE, "members": [deployer]},
|
||||||
|
{
|
||||||
|
"role": "roles/cloudsql.client",
|
||||||
|
"members": [runtime],
|
||||||
|
"condition": {
|
||||||
|
"title": "observatory-exact-cloudsql-instance",
|
||||||
|
"expression": (
|
||||||
|
"resource.name == 'projects/teleo-501523/instances/teleo-pgvector-standby' "
|
||||||
|
"&& resource.service == 'sqladmin.googleapis.com'"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "roles/cloudsql.instanceUser",
|
||||||
|
"members": [runtime],
|
||||||
|
"condition": {
|
||||||
|
"title": "observatory-exact-cloudsql-instance",
|
||||||
|
"expression": (
|
||||||
|
"resource.name == 'projects/teleo-501523/instances/teleo-pgvector-standby' "
|
||||||
|
"&& resource.service == 'sqladmin.googleapis.com'"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "roles/run.serviceAgent",
|
||||||
|
"members": [
|
||||||
|
"serviceAccount:service-785938879453@serverless-robot-prod.iam.gserviceaccount.com"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
deployer_policy = {
|
||||||
|
"bindings": [
|
||||||
|
{"role": "roles/iam.workloadIdentityUser", "members": [preflight.DEPLOY_WIF_MEMBER]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
runtime_policy = {
|
||||||
|
"bindings": [{"role": "roles/iam.serviceAccountUser", "members": [deployer]}]
|
||||||
|
}
|
||||||
|
artifact_policy = {
|
||||||
|
"bindings": [{"role": "roles/artifactregistry.reader", "members": [deployer]}]
|
||||||
|
}
|
||||||
|
secret_policy = {
|
||||||
|
"bindings": [
|
||||||
|
{"role": "roles/secretmanager.secretAccessor", "members": [deployer, runtime]},
|
||||||
|
{"role": "roles/secretmanager.viewer", "members": [deployer]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
provider = {
|
||||||
|
"state": "ACTIVE",
|
||||||
|
"attributeCondition": preflight.DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
||||||
|
"attributeMapping": {
|
||||||
|
"google.subject": "assertion.sub",
|
||||||
|
"attribute.repository": "assertion.repository",
|
||||||
|
"attribute.ref": "assertion.ref",
|
||||||
|
"attribute.workflow_ref": "assertion.workflow_ref",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
joined = " ".join(command)
|
||||||
|
stdout = ""
|
||||||
|
stderr = ""
|
||||||
|
returncode = 0
|
||||||
|
if "auth list" in joined:
|
||||||
|
stdout = "sa-observatory-deployer@teleo-501523.iam.gserviceaccount.com\n"
|
||||||
|
elif "projects describe" in joined:
|
||||||
|
stdout = "teleo-501523\n"
|
||||||
|
elif command[:3] == ["gcloud", "services", "describe"]:
|
||||||
|
stdout = "ENABLED\n"
|
||||||
|
elif "workload-identity-pools providers describe" in joined:
|
||||||
|
stdout = json.dumps(provider)
|
||||||
|
elif "iam roles describe observatoryStagingPreflight" in joined:
|
||||||
|
stdout = json.dumps({"includedPermissions": sorted(preflight.PREFLIGHT_PERMISSIONS)})
|
||||||
|
elif "iam roles describe observatoryStagingDeployer" in joined:
|
||||||
|
stdout = json.dumps({"includedPermissions": sorted(preflight.DEPLOY_PERMISSIONS)})
|
||||||
|
elif "projects get-iam-policy" in joined:
|
||||||
|
stdout = json.dumps(project_policy)
|
||||||
|
elif f"service-accounts get-iam-policy {preflight.DEPLOY_SERVICE_ACCOUNT}" in joined:
|
||||||
|
stdout = json.dumps(deployer_policy)
|
||||||
|
elif f"service-accounts get-iam-policy {preflight.RUNTIME_SERVICE_ACCOUNT}" in joined:
|
||||||
|
stdout = json.dumps(runtime_policy)
|
||||||
|
elif "artifacts repositories get-iam-policy" in joined:
|
||||||
|
stdout = json.dumps(artifact_policy)
|
||||||
|
elif "secrets get-iam-policy" in joined:
|
||||||
|
stdout = json.dumps(secret_policy)
|
||||||
|
elif "run services describe" in joined:
|
||||||
|
returncode = 1
|
||||||
|
stderr = "NOT_FOUND: service not found"
|
||||||
|
elif "service-accounts describe" in joined:
|
||||||
|
stdout = "sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com\n"
|
||||||
|
elif "networks subnets describe" in joined:
|
||||||
|
stdout = json.dumps(
|
||||||
|
{
|
||||||
|
"name": "teleo-staging-europe-west6",
|
||||||
|
"region": "https://www.googleapis.com/compute/v1/projects/teleo-501523/regions/europe-west6",
|
||||||
|
"network": "https://www.googleapis.com/compute/v1/projects/teleo-501523/global/networks/teleo-staging-net",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif "networks describe" in joined:
|
||||||
|
stdout = "teleo-staging-net\n"
|
||||||
|
elif "sql instances describe" in joined:
|
||||||
|
stdout = json.dumps(instance)
|
||||||
|
elif "sql users list" in joined:
|
||||||
|
stdout = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "sa-observatory-read-adapter@teleo-501523.iam",
|
||||||
|
"type": "CLOUD_IAM_SERVICE_ACCOUNT",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif "secrets versions list" in joined:
|
||||||
|
stdout = "7\n"
|
||||||
|
elif "secrets versions access" in joined:
|
||||||
|
stdout = secret_value
|
||||||
|
elif "artifacts docker images describe" in joined:
|
||||||
|
stdout = "{}"
|
||||||
|
return subprocess.CompletedProcess(command, returncode, stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
monkeypatch.setattr(preflight, "run", fake_run)
|
||||||
|
image_ref = (
|
||||||
|
"europe-west6-docker.pkg.dev/teleo-501523/teleo/observatory-read-adapter:"
|
||||||
|
"33399bd9acfa09e4e5409873e86c8c46ac694a8d-29389090997-1@sha256:" + "a" * 64
|
||||||
|
)
|
||||||
|
checks = preflight.build_checks(image_ref)
|
||||||
|
|
||||||
|
assert checks
|
||||||
|
assert {check.status for check in checks} == {"pass"}
|
||||||
|
assert secret_value not in json.dumps([check.__dict__ for check in checks])
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_successful_readback_becomes_blocked_check(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
preflight,
|
||||||
|
"run",
|
||||||
|
lambda command: subprocess.CompletedProcess(command, 0, stdout="[]", stderr=""),
|
||||||
|
)
|
||||||
|
|
||||||
|
check = preflight.command_check(
|
||||||
|
"malformed",
|
||||||
|
["gcloud", "sql", "instances", "describe"],
|
||||||
|
preflight.cloud_sql_is_private_and_iam_enabled,
|
||||||
|
"unexpected",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert check.status == "blocked"
|
||||||
|
assert check.detail == "invalid_readback:AttributeError"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(shutil.which("jq") is None, reason="jq is required")
|
||||||
|
def test_receipt_redaction_is_an_explicit_allowlist() -> None:
|
||||||
|
payload = {
|
||||||
|
"schema": "livingip.observatory-canonical-claim.v1",
|
||||||
|
"read_only": True,
|
||||||
|
"provenance": {
|
||||||
|
"gcp_project": "teleo-501523",
|
||||||
|
"cloud_sql_instance": "teleo-501523:europe-west6:teleo-pgvector-standby",
|
||||||
|
"database": "teleo_canonical",
|
||||||
|
"database_principal": "sa-observatory-read-adapter@teleo-501523.iam",
|
||||||
|
"authorization_role": "kb_observatory_read",
|
||||||
|
"transaction_read_only": True,
|
||||||
|
"write_privileges_denied": True,
|
||||||
|
"service_revision": "a" * 40,
|
||||||
|
"future_sensitive_field": "must-not-survive",
|
||||||
|
},
|
||||||
|
"canonical": {
|
||||||
|
"claim": {"id": "claim-1", "type": "fact", "status": "live", "text": "secret claim text"},
|
||||||
|
"evidence": [{"excerpt": "secret excerpt", "source_url": "https://private.invalid"}],
|
||||||
|
"edges": [{"direction": "incoming"}, {"direction": "outgoing"}],
|
||||||
|
},
|
||||||
|
"proposal_ledger": {
|
||||||
|
"distinct_from_canonical": True,
|
||||||
|
"status_counts": {"approved": 1},
|
||||||
|
"related": [{"proposal_text": "secret proposal"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = subprocess.run(
|
||||||
|
["jq", "-f", str(ROOT / "ops" / "redact_observatory_read_adapter_receipt.jq")],
|
||||||
|
input=json.dumps(payload),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
redacted = json.loads(result.stdout)
|
||||||
|
rendered = json.dumps(redacted)
|
||||||
|
|
||||||
|
assert set(redacted["provenance"]) == {
|
||||||
|
"gcp_project",
|
||||||
|
"cloud_sql_instance",
|
||||||
|
"database",
|
||||||
|
"database_principal",
|
||||||
|
"authorization_role",
|
||||||
|
"transaction_read_only",
|
||||||
|
"write_privileges_denied",
|
||||||
|
"service_revision",
|
||||||
|
}
|
||||||
|
assert redacted["canonical"]["evidence_count"] == 1
|
||||||
|
assert redacted["proposal_ledger"]["related_count"] == 1
|
||||||
|
for sensitive in (
|
||||||
|
"must-not-survive",
|
||||||
|
"secret claim text",
|
||||||
|
"secret excerpt",
|
||||||
|
"https://private.invalid",
|
||||||
|
"secret proposal",
|
||||||
|
):
|
||||||
|
assert sensitive not in rendered
|
||||||
|
|
@ -44,6 +44,7 @@ def run(command: list[str], *, input_text: str | None = None, check: bool = True
|
||||||
@pytest.mark.skipif(shutil.which("docker") is None, reason="Docker is required")
|
@pytest.mark.skipif(shutil.which("docker") is None, reason="Docker is required")
|
||||||
def test_observatory_role_is_read_only_and_exactly_allowlisted() -> None:
|
def test_observatory_role_is_read_only_and_exactly_allowlisted() -> None:
|
||||||
container = f"teleo-observatory-role-{uuid.uuid4().hex[:10]}"
|
container = f"teleo-observatory-role-{uuid.uuid4().hex[:10]}"
|
||||||
|
try:
|
||||||
run(
|
run(
|
||||||
[
|
[
|
||||||
"docker",
|
"docker",
|
||||||
|
|
@ -59,7 +60,6 @@ def test_observatory_role_is_read_only_and_exactly_allowlisted() -> None:
|
||||||
POSTGRES_IMAGE,
|
POSTGRES_IMAGE,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
deadline = time.monotonic() + 25
|
deadline = time.monotonic() + 25
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
ready = run(
|
ready = run(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue