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:
|
||||
inputs:
|
||||
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
|
||||
default: build_only
|
||||
type: choice
|
||||
options:
|
||||
- build_only
|
||||
- preflight_staging
|
||||
- deploy_staging
|
||||
|
||||
permissions:
|
||||
|
|
@ -28,10 +29,13 @@ env:
|
|||
IMAGE_NAME: observatory-read-adapter
|
||||
SERVICE_NAME: observatory-read-adapter-staging
|
||||
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
|
||||
API_KEY_SECRET: observatory-read-api-key-staging
|
||||
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
|
||||
BUILD_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
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:
|
||||
build:
|
||||
|
|
@ -57,8 +61,8 @@ jobs:
|
|||
- id: auth
|
||||
uses: google-github-actions/auth@v3
|
||||
with:
|
||||
workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }}
|
||||
workload_identity_provider: ${{ env.BUILD_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.BUILD_SERVICE_ACCOUNT }}
|
||||
|
||||
- uses: google-github-actions/setup-gcloud@v3
|
||||
with:
|
||||
|
|
@ -68,28 +72,21 @@ jobs:
|
|||
run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
|
||||
|
||||
- id: image
|
||||
name: Build, smoke, and push immutable image
|
||||
name: Build, smoke, and push run-unique immutable image
|
||||
shell: bash
|
||||
run: |
|
||||
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}"
|
||||
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}" .
|
||||
fi
|
||||
docker run --rm --env PYTHONPYCACHEPREFIX=/tmp/pycache \
|
||||
"${image}" python -m compileall -q /app/observatory_read_adapter
|
||||
docker run --rm "${image}" python -c 'import observatory_read_adapter; print("adapter-import-ok")'
|
||||
if [[ -z "${digest}" ]]; then
|
||||
docker push "${image}" | tee docker-push.log
|
||||
digest="$(awk '/digest: sha256:/ {print $3}' docker-push.log | tail -1)"
|
||||
fi
|
||||
test -n "${digest}"
|
||||
docker push "${image}"
|
||||
digest="$(gcloud artifacts docker images describe "${image}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--format='value(image_summary.digest)')"
|
||||
[[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||
image_ref="${image}@${digest}"
|
||||
printf 'image_ref=%s\n' "${image_ref}" >> "${GITHUB_OUTPUT}"
|
||||
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
|
||||
if-no-files-found: error
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy and prove staging read path
|
||||
if: ${{ inputs.action == 'deploy_staging' }}
|
||||
preflight-staging:
|
||||
name: Verify private staging prerequisites
|
||||
if: ${{ inputs.action != 'build_only' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 10
|
||||
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.WORKLOAD_IDENTITY_PROVIDER }}
|
||||
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 }}
|
||||
|
||||
- name: Deploy bounded GCP staging service
|
||||
- name: Run adapter-specific read-only GCP preflight
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
||||
run: |
|
||||
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}" \
|
||||
--project="${PROJECT_ID}" \
|
||||
--region="${REGION}" \
|
||||
|
|
@ -135,39 +195,69 @@ jobs:
|
|||
--subnet=teleo-staging-europe-west6 \
|
||||
--vpc-egress=private-ranges-only \
|
||||
--ingress=all \
|
||||
--allow-unauthenticated \
|
||||
--no-invoker-iam-check \
|
||||
--port=8080 \
|
||||
--cpu=1 \
|
||||
--memory=512Mi \
|
||||
--concurrency=8 \
|
||||
--max-instances=2 \
|
||||
--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}" \
|
||||
--labels="livingip-tier=gcp-staging,livingip-surface=observatory-read-adapter" \
|
||||
--quiet
|
||||
printf 'api_key_version=%s\n' "${api_key_version}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Capture positive and negative live receipts
|
||||
shell: bash
|
||||
env:
|
||||
API_KEY_VERSION: ${{ steps.deploy.outputs.api_key_version }}
|
||||
EXPECTED_IMAGE_REF: ${{ needs.build.outputs.image_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
service_url="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format='value(status.url)')"
|
||||
revision="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format='value(status.latestReadyRevisionName)')"
|
||||
api_key="$(gcloud secrets versions access latest --secret="${API_KEY_SECRET}" --project="${PROJECT_ID}")"
|
||||
service_json="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format=json)"
|
||||
service_url="$(jq -r '.status.url // empty' <<<"${service_json}")"
|
||||
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 "${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}"
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
--header "X-Api-Key: ${api_key}" \
|
||||
"${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"
|
||||
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_principal == $db_iam_user
|
||||
and .provenance.authorization_role == "kb_observatory_read"
|
||||
and .provenance.transaction_read_only == true
|
||||
and .provenance.write_privileges_denied == true
|
||||
and .provenance.service_revision == $git_sha
|
||||
and (.canonical.evidence | length) > 0
|
||||
and .proposal_ledger.distinct_from_canonical == true
|
||||
' positive.json >/dev/null
|
||||
|
|
@ -178,12 +268,23 @@ jobs:
|
|||
--data '{"status":"applied"}' "${service_url}/v1/claims/sample")"
|
||||
test "${anonymous_status}" = 401
|
||||
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 \
|
||||
--arg service_url "${service_url}" \
|
||||
--arg revision "${revision}" \
|
||||
--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 write_status "${write_status}" \
|
||||
'{
|
||||
|
|
@ -192,14 +293,21 @@ jobs:
|
|||
service_url: $service_url,
|
||||
revision: $revision,
|
||||
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,
|
||||
negative: {
|
||||
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
|
||||
}' > observatory-read-adapter-live-receipt.json
|
||||
unset api_key
|
||||
rm -f positive.json positive-redacted.json after-write-attempt.json anonymous.json write.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
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`.
|
||||
- Cloud SQL: `teleo-501523:europe-west6:teleo-pgvector-standby`, private IP.
|
||||
- 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`.
|
||||
- IAM database user: `sa-observatory-read-adapter@teleo-501523.iam`.
|
||||
- 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;
|
||||
they do not route production traffic.
|
||||
|
||||
1. Create the dedicated runtime service account and grant only
|
||||
`roles/cloudsql.client` and `roles/cloudsql.instanceUser` in project
|
||||
`teleo-501523`.
|
||||
2. Add the service account as a Cloud SQL IAM service-account user on
|
||||
`teleo-pgvector-standby`.
|
||||
3. From the existing private GCP VM database-admin path, run
|
||||
1. Reauthenticate the project operator, then run the first full direct check:
|
||||
|
||||
```bash
|
||||
gcloud projects describe teleo-501523 --format=value(projectId)
|
||||
python3 ops/check_gcp_infra_readiness.py
|
||||
```
|
||||
|
||||
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
|
||||
`OBSERVATORY_DB_IAM_USER=sa-observatory-read-adapter@teleo-501523.iam`.
|
||||
Do not read, copy, or retain the administrator password.
|
||||
4. Create `observatory-read-api-key-staging`, add a generated 32-byte-or-longer
|
||||
value through stdin, and grant that secret's accessor role only to the
|
||||
runtime service account and the bounded staging canary identity.
|
||||
5. Dispatch `.github/workflows/gcp-observatory-read-adapter.yml` with
|
||||
`action=deploy_staging` from the exact reviewed branch revision.
|
||||
5. Create `observatory-read-api-key-staging`, add a generated whitespace-free
|
||||
32-byte-or-longer value through stdin only when no enabled version exists,
|
||||
and grant that secret's accessor role only to the runtime service account
|
||||
and the dedicated staging deploy/canary identity. Deployment pins the exact
|
||||
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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"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": [
|
||||
{
|
||||
"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",
|
||||
"result": "token refresh and service enable both stop at noninteractive Google reauthentication"
|
||||
"route": "GitHub Actions GCP readiness with artifact-builder WIF, run 29389090997",
|
||||
"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",
|
||||
"result": "token refresh stops at noninteractive Google reauthentication"
|
||||
"route": "local billy@livingip.xyz gcloud user and application-default credentials",
|
||||
"result": "both require interactive Google reauthentication before project-admin API or IAM changes"
|
||||
},
|
||||
{
|
||||
"route": "other locally authenticated Google accounts",
|
||||
|
|
@ -22,19 +23,30 @@
|
|||
{
|
||||
"route": "fixed read-only GCP IAP status workflow",
|
||||
"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": {
|
||||
"first_infrastructure_gate": "Cloud Run Admin API run.googleapis.com is disabled in teleo-501523.",
|
||||
"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.",
|
||||
"alternate_private_operator_gate": "The read-only teleo-iap-operator WIF provider returns invalid_target and cannot reach its status operation."
|
||||
"non_gui_federation": "Working. sa-artifact-builder can federate through living-ip-github and read/push Artifact Registry resources.",
|
||||
"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.",
|
||||
"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.",
|
||||
"clear_CTA": "Run `gcloud auth login billy@livingip.xyz --update-adc`, complete Google password or MFA, and then report `GCP reauth complete`.",
|
||||
"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.",
|
||||
"production_boundary": "Do not change Vercel, production routing, Cloud SQL network exposure, or any write endpoint."
|
||||
"latest_direct_non_gui_check": {
|
||||
"active_account": "billy@livingip.xyz",
|
||||
"gcloud_projects_describe": "blocked: reauthentication failed and non-interactive execution cannot prompt",
|
||||
"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",
|
||||
"status": "blocked_before_runtime_creation",
|
||||
"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",
|
||||
"region": "europe-west6",
|
||||
"service_name": "observatory-read-adapter-staging",
|
||||
|
|
@ -26,6 +26,18 @@
|
|||
"failure_detail": "run.googleapis.com is disabled in teleo-501523",
|
||||
"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": {
|
||||
"service_url": null,
|
||||
"authenticated_get_sample": null,
|
||||
|
|
@ -47,9 +59,26 @@
|
|||
"orphan_local_build_processes": 0,
|
||||
"artifact_registry_image_retained_for_next_deploy": true
|
||||
},
|
||||
"proof_paths": [
|
||||
"/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"
|
||||
],
|
||||
"claim_ceiling": "The immutable staging image exists, but no live API or database query receipt exists. T3 is not complete."
|
||||
"source_artifacts": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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")
|
||||
def test_observatory_role_is_read_only_and_exactly_allowlisted() -> None:
|
||||
container = f"teleo-observatory-role-{uuid.uuid4().hex[:10]}"
|
||||
try:
|
||||
run(
|
||||
[
|
||||
"docker",
|
||||
|
|
@ -59,7 +60,6 @@ def test_observatory_role_is_read_only_and_exactly_allowlisted() -> None:
|
|||
POSTGRES_IMAGE,
|
||||
]
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 25
|
||||
while time.monotonic() < deadline:
|
||||
ready = run(
|
||||
|
|
|
|||
Loading…
Reference in a new issue