diff --git a/.github/workflows/gcp-observatory-read-adapter.yml b/.github/workflows/gcp-observatory-read-adapter.yml index 95b18a4..bc30517 100644 --- a/.github/workflows/gcp-observatory-read-adapter.yml +++ b/.github/workflows/gcp-observatory-read-adapter.yml @@ -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 build -f Dockerfile.observatory-read-adapter -t "${image}" . 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: diff --git a/docs/observatory-read-adapter-gcp-staging.md b/docs/observatory-read-adapter-gcp-staging.md index b4d6960..23837c1 100644 --- a/docs/observatory-read-adapter-gcp-staging.md +++ b/docs/observatory-read-adapter-gcp-staging.md @@ -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 diff --git a/docs/reports/observatory-read-adapter/gcp-oidc-readiness-29389090997-redacted.json b/docs/reports/observatory-read-adapter/gcp-oidc-readiness-29389090997-redacted.json new file mode 100644 index 0000000..804e50b --- /dev/null +++ b/docs/reports/observatory-read-adapter/gcp-oidc-readiness-29389090997-redacted.json @@ -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." +} diff --git a/docs/reports/observatory-read-adapter/gcp-staging-current-gate.json b/docs/reports/observatory-read-adapter/gcp-staging-current-gate.json index f87f8cb..c6b44a0 100644 --- a/docs/reports/observatory-read-adapter/gcp-staging-current-gate.json +++ b/docs/reports/observatory-read-adapter/gcp-staging-current-gate.json @@ -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" + ] } diff --git a/docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json b/docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json index 8c50ace..7fcd98f 100644 --- a/docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json +++ b/docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json @@ -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" + "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" + } ], - "claim_ceiling": "The immutable staging image exists, but no live API or database query receipt exists. T3 is not complete." + "proof_paths": [ + "docs/reports/observatory-read-adapter/gcp-staging-live-attempt-redacted.json", + "docs/reports/observatory-read-adapter/gcp-oidc-readiness-29389090997-redacted.json" + ], + "claim_ceiling": "The immutable staging image and working artifact-only OIDC path exist, but no live API or database query receipt exists. T3 is not complete." } diff --git a/ops/check_observatory_read_adapter_gcp_preflight.py b/ops/check_observatory_read_adapter_gcp_preflight.py new file mode 100644 index 0000000..8c75e43 --- /dev/null +++ b/ops/check_observatory_read_adapter_gcp_preflight.py @@ -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", "", 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()) diff --git a/ops/plan_observatory_read_adapter_gcp.py b/ops/plan_observatory_read_adapter_gcp.py new file mode 100644 index 0000000..1bd236c --- /dev/null +++ b/ops/plan_observatory_read_adapter_gcp.py @@ -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()) diff --git a/ops/redact_observatory_read_adapter_receipt.jq b/ops/redact_observatory_read_adapter_receipt.jq new file mode 100644 index 0000000..e8aa2c8 --- /dev/null +++ b/ops/redact_observatory_read_adapter_receipt.jq @@ -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) + } +} diff --git a/tests/test_observatory_read_adapter_gcp_plan.py b/tests/test_observatory_read_adapter_gcp_plan.py new file mode 100644 index 0000000..d5fda32 --- /dev/null +++ b/tests/test_observatory_read_adapter_gcp_plan.py @@ -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 diff --git a/tests/test_observatory_read_role_postgres.py b/tests/test_observatory_read_role_postgres.py index be2b155..c062b1d 100644 --- a/tests/test_observatory_read_role_postgres.py +++ b/tests/test_observatory_read_role_postgres.py @@ -44,22 +44,22 @@ 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]}" - run( - [ - "docker", - "run", - "--detach", - "--rm", - "--name", - container, - "--env", - f"POSTGRES_PASSWORD={TEST_PASSWORD}", - "--env", - f"POSTGRES_DB={DATABASE}", - POSTGRES_IMAGE, - ] - ) try: + run( + [ + "docker", + "run", + "--detach", + "--rm", + "--name", + container, + "--env", + f"POSTGRES_PASSWORD={TEST_PASSWORD}", + "--env", + f"POSTGRES_DB={DATABASE}", + POSTGRES_IMAGE, + ] + ) deadline = time.monotonic() + 25 while time.monotonic() < deadline: ready = run(