teleo-infrastructure/ops/plan_gcp_iap_operator_access.py

752 lines
26 KiB
Python
Executable file

#!/usr/bin/env python3
"""Emit the dry-run bootstrap and revocation plan for GitHub-to-GCP IAP SSH."""
from __future__ import annotations
import argparse
import json
import shlex
PROJECT = "teleo-501523"
PROJECT_NUMBER = "785938879453"
GITHUB_REPOSITORY = "living-ip/teleo-infrastructure"
WORKFLOW_PATH = ".github/workflows/gcp-iap-operator.yml"
WORKFLOW_REF = f"{GITHUB_REPOSITORY}/{WORKFLOW_PATH}@refs/heads/main"
POOL = "github-actions"
PROVIDER = "teleo-iap-operator"
PROVIDER_RESOURCE = f"projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{POOL}/providers/{PROVIDER}"
ZONE = "europe-west6-a"
INSTANCE = "teleo-prod-1"
INSTANCE_INTERNAL_IP = "10.60.0.3"
NETWORK = "teleo-staging-net"
TARGET_TAG = "teleo-prod-ssh"
VM_SERVICE_ACCOUNT = f"sa-teleo-prod-vm@{PROJECT}.iam.gserviceaccount.com"
STATUS_SERVICE_ACCOUNT_ID = "sa-teleo-iap-status"
STATUS_SERVICE_ACCOUNT = f"{STATUS_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
CLONE_SERVICE_ACCOUNT_ID = "sa-teleo-iap-clone-operator"
CLONE_SERVICE_ACCOUNT = f"{CLONE_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
CUSTOM_ROLE_ID = "teleoIapSshDiscovery"
CUSTOM_ROLE = f"projects/{PROJECT}/roles/{CUSTOM_ROLE_ID}"
CUSTOM_PERMISSIONS = [
"compute.instances.get",
"compute.instances.list",
"compute.projects.get",
]
IAP_ROLE = "roles/iap.tunnelResourceAccessor"
IAP_CONDITION = f'destination.ip == "{INSTANCE_INTERNAL_IP}" && destination.port == 22'
PROVIDER_CONDITION = " && ".join(
[
f"assertion.repository == '{GITHUB_REPOSITORY}'",
"assertion.ref == 'refs/heads/main'",
f"assertion.workflow_ref == '{WORKFLOW_REF}'",
"assertion.event_name == 'workflow_dispatch'",
]
)
ATTRIBUTE_MAPPING = ",".join(
[
"google.subject=assertion.sub",
"attribute.repository=assertion.repository",
"attribute.ref=assertion.ref",
"attribute.workflow_ref=assertion.workflow_ref",
"attribute.event_name=assertion.event_name",
]
)
WIF_PRINCIPAL = (
f"principal://iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/"
f"workloadIdentityPools/{POOL}/subject/repo:{GITHUB_REPOSITORY}:ref:refs/heads/main"
)
IAP_FIREWALL_RULE = "teleo-prod-allow-ssh-iap"
CHANGING_PUBLIC_RULE = "teleo-prod-allow-ssh-current-ip"
IAP_SOURCE_RANGE = "35.235.240.0/20"
DISPATCHER_VERSION = "teleo-gcp-iap-operator-v1"
FORBIDDEN_OPERATOR_ROLES = [
"roles/compute.admin",
"roles/secretmanager.admin",
"roles/secretmanager.secretAccessor",
"roles/cloudsql.admin",
"roles/artifactregistry.admin",
"roles/artifactregistry.writer",
]
def shell_join(parts: list[str]) -> str:
return " ".join(shlex.quote(part) for part in parts)
def service_account_member(email: str) -> str:
return f"serviceAccount:{email}"
def bootstrap_commands() -> list[str]:
accounts = [
(STATUS_SERVICE_ACCOUNT_ID, "Teleo IAP status canary"),
(CLONE_SERVICE_ACCOUNT_ID, "Teleo IAP clone operator"),
]
commands = [
shell_join(
[
"gcloud",
"services",
"enable",
"compute.googleapis.com",
"iap.googleapis.com",
"iamcredentials.googleapis.com",
"sts.googleapis.com",
"--project",
PROJECT,
]
),
shell_join(
[
"gcloud",
"iam",
"workload-identity-pools",
"describe",
POOL,
"--location",
"global",
"--project",
PROJECT,
]
),
shell_join(
[
"gcloud",
"iam",
"workload-identity-pools",
"providers",
"create-oidc",
PROVIDER,
"--location",
"global",
"--workload-identity-pool",
POOL,
"--project",
PROJECT,
"--issuer-uri",
"https://token.actions.githubusercontent.com",
"--attribute-mapping",
ATTRIBUTE_MAPPING,
"--attribute-condition",
PROVIDER_CONDITION,
"--display-name",
"Teleo fixed IAP operator workflow",
]
),
*[
shell_join(
[
"gcloud",
"iam",
"service-accounts",
"create",
account_id,
"--project",
PROJECT,
"--display-name",
display_name,
]
)
for account_id, display_name in accounts
],
shell_join(
[
"gcloud",
"iam",
"roles",
"create",
CUSTOM_ROLE_ID,
"--project",
PROJECT,
"--title",
"Teleo exact-instance SSH discovery",
"--description",
"Only the instance and project reads required by gcloud compute ssh",
"--permissions",
",".join(CUSTOM_PERMISSIONS),
"--stage",
"GA",
]
),
]
for account in (STATUS_SERVICE_ACCOUNT, CLONE_SERVICE_ACCOUNT):
member = service_account_member(account)
commands.extend(
[
shell_join(
[
"gcloud",
"iam",
"service-accounts",
"add-iam-policy-binding",
account,
"--project",
PROJECT,
"--member",
WIF_PRINCIPAL,
"--role",
"roles/iam.workloadIdentityUser",
]
),
shell_join(
[
"gcloud",
"projects",
"add-iam-policy-binding",
PROJECT,
"--member",
member,
"--role",
CUSTOM_ROLE,
]
),
shell_join(
[
"gcloud",
"projects",
"add-iam-policy-binding",
PROJECT,
"--member",
member,
"--role",
IAP_ROLE,
"--condition",
f"expression={IAP_CONDITION},title=teleo-prod-1-ssh-only",
]
),
shell_join(
[
"gcloud",
"iam",
"service-accounts",
"add-iam-policy-binding",
VM_SERVICE_ACCOUNT,
"--project",
PROJECT,
"--member",
member,
"--role",
"roles/iam.serviceAccountUser",
]
),
]
)
commands.extend(
[
shell_join(
[
"gcloud",
"compute",
"instances",
"add-iam-policy-binding",
INSTANCE,
"--zone",
ZONE,
"--project",
PROJECT,
"--member",
service_account_member(STATUS_SERVICE_ACCOUNT),
"--role",
"roles/compute.osLogin",
]
),
shell_join(
[
"gcloud",
"compute",
"instances",
"add-iam-policy-binding",
INSTANCE,
"--zone",
ZONE,
"--project",
PROJECT,
"--member",
service_account_member(CLONE_SERVICE_ACCOUNT),
"--role",
"roles/compute.osAdminLogin",
]
),
shell_join(
[
"gcloud",
"compute",
"instances",
"add-metadata",
INSTANCE,
"--zone",
ZONE,
"--project",
PROJECT,
"--metadata",
"enable-oslogin=TRUE",
]
),
shell_join(
[
"gcloud",
"compute",
"firewall-rules",
"create",
IAP_FIREWALL_RULE,
"--project",
PROJECT,
"--network",
NETWORK,
"--direction",
"INGRESS",
"--action",
"ALLOW",
"--rules",
"tcp:22",
"--source-ranges",
IAP_SOURCE_RANGE,
"--target-tags",
TARGET_TAG,
"--enable-logging",
]
),
"# Install the reviewed dispatcher through the existing admin path before removing that path.",
'BOOTSTRAP_SSH_DIR=$(mktemp -d "${TMPDIR:-/tmp}/teleo-iap-bootstrap.XXXXXX"); '
'chmod 0700 "${BOOTSTRAP_SSH_DIR}"; '
'BOOTSTRAP_SSH_KEY="${BOOTSTRAP_SSH_DIR}/ssh_key"; '
"trap 'rm -rf \"${BOOTSTRAP_SSH_DIR}\"' EXIT",
"install -m 0600 scripts/gcp_iap_operator.sh "
'"${BOOTSTRAP_SSH_DIR}/gcp_iap_operator.sh"; '
'(cd "${BOOTSTRAP_SSH_DIR}" && sha256sum gcp_iap_operator.sh > dispatcher.sha256); '
'chmod 0600 "${BOOTSTRAP_SSH_DIR}/dispatcher.sha256"',
"gcloud compute scp "
'"${BOOTSTRAP_SSH_DIR}/gcp_iap_operator.sh" '
'"${BOOTSTRAP_SSH_DIR}/dispatcher.sha256" '
f"{INSTANCE}:/tmp/ --zone {ZONE} --project {PROJECT} "
'--tunnel-through-iap --ssh-key-file="${BOOTSTRAP_SSH_KEY}" '
'--ssh-key-expire-after=5m',
shell_join(
[
"gcloud",
"compute",
"ssh",
INSTANCE,
"--zone",
ZONE,
"--project",
PROJECT,
"--command",
"cd /tmp && sha256sum -c dispatcher.sha256 && "
f"grep -F 'readonly DISPATCHER_VERSION=\"{DISPATCHER_VERSION}\"' gcp_iap_operator.sh && "
"sudo install -o root -g root -m 0755 /tmp/gcp_iap_operator.sh "
"/usr/local/sbin/teleo-gcp-iap-operator && "
"test \"$(sudo sha256sum /usr/local/sbin/teleo-gcp-iap-operator | awk '{print $1}')\" "
"= \"$(awk '{print $1}' /tmp/dispatcher.sha256)\" && "
"sudo install -d -o root -g root -m 0700 /var/lib/teleo-gcp-iap-operator/runs && "
"rm -f /tmp/gcp_iap_operator.sh /tmp/dispatcher.sha256",
]
)
+ ' --tunnel-through-iap --ssh-key-file="${BOOTSTRAP_SSH_KEY}" '
'--ssh-key-expire-after=5m',
]
)
return commands
def status_verification_commands() -> list[str]:
request_id = "iap-bootstrapstatus01"
return [
shell_join(
[
"gh",
"workflow",
"run",
"gcp-iap-operator.yml",
"--repo",
GITHUB_REPOSITORY,
"--ref",
"main",
"-f",
"operation=status",
"-f",
f"request_id={request_id}",
"-f",
"target_db=teleo_clone_status",
]
),
shell_join(
[
"gh",
"run",
"list",
"--repo",
GITHUB_REPOSITORY,
"--workflow",
"gcp-iap-operator.yml",
"--event",
"workflow_dispatch",
"--limit",
"1",
]
),
"IAP_RUN_ID=$(gh run list --repo "
f"{GITHUB_REPOSITORY} --workflow gcp-iap-operator.yml --event workflow_dispatch --limit 1 "
"--json databaseId --jq '.[0].databaseId'); test -n \"${IAP_RUN_ID}\"",
'IAP_VERIFY_DIR=$(mktemp -d "${TMPDIR:-/tmp}/teleo-iap-verify.XXXXXX"); '
'chmod 0700 "${IAP_VERIFY_DIR}"; trap \'rm -rf "${IAP_VERIFY_DIR}"\' EXIT',
'gh run watch "${IAP_RUN_ID}" --repo ' + GITHUB_REPOSITORY + " --exit-status",
'gh run download "${IAP_RUN_ID}" --repo '
+ GITHUB_REPOSITORY
+ ' --name "gcp-iap-operator-${IAP_RUN_ID}" --dir "${IAP_VERIFY_DIR}"',
"python3 -c 'import json,sys; p=json.load(open(sys.argv[1])); "
"import hashlib,pathlib; "
'r=p.get("remote_result") or {}; assert p.get("status")=="pass"; '
f'assert p.get("request_id")=="{request_id}"; '
'assert p.get("operation")=="status"; '
f'assert r.get("expected_internal_ip")=="{INSTANCE_INTERNAL_IP}"; '
f'assert r.get("dispatcher_version")=="{DISPATCHER_VERSION}"; '
'assert r.get("dispatcher_sha256")==hashlib.sha256('
'pathlib.Path("scripts/gcp_iap_operator.sh").read_bytes()).hexdigest(); '
'assert r.get("active_state")=="active"\' "${IAP_VERIFY_DIR}/result.json"',
"# The old public /32 rule may be disabled only after every command above succeeds.",
shell_join(
[
"gcloud",
"compute",
"firewall-rules",
"update",
CHANGING_PUBLIC_RULE,
"--project",
PROJECT,
"--disabled",
]
),
shell_join(
[
"gcloud",
"compute",
"firewall-rules",
"describe",
CHANGING_PUBLIC_RULE,
"--project",
PROJECT,
"--format=value(disabled,sourceRanges)",
]
),
]
def revocation_commands() -> list[str]:
commands = [
"# Remove the remote dispatcher while IAP access still exists, then revoke cloud access.",
'REVOKE_SSH_DIR=$(mktemp -d "${TMPDIR:-/tmp}/teleo-iap-revoke.XXXXXX"); '
'chmod 0700 "${REVOKE_SSH_DIR}"; REVOKE_SSH_KEY="${REVOKE_SSH_DIR}/ssh_key"; '
"trap 'rm -rf \"${REVOKE_SSH_DIR}\"' EXIT",
shell_join(
[
"gcloud",
"compute",
"ssh",
INSTANCE,
"--zone",
ZONE,
"--project",
PROJECT,
"--tunnel-through-iap",
"--command",
"sudo rm -f /usr/local/sbin/teleo-gcp-iap-operator",
]
)
+ ' --ssh-key-file="${REVOKE_SSH_KEY}" --ssh-key-expire-after=5m',
shell_join(
[
"gcloud",
"iam",
"workload-identity-pools",
"providers",
"update-oidc",
PROVIDER,
"--location",
"global",
"--workload-identity-pool",
POOL,
"--project",
PROJECT,
"--disabled",
]
),
shell_join(
[
"gcloud",
"compute",
"firewall-rules",
"delete",
IAP_FIREWALL_RULE,
"--project",
PROJECT,
"--quiet",
]
),
]
for account, os_role in (
(STATUS_SERVICE_ACCOUNT, "roles/compute.osLogin"),
(CLONE_SERVICE_ACCOUNT, "roles/compute.osAdminLogin"),
):
member = service_account_member(account)
commands.extend(
[
shell_join(
[
"gcloud",
"compute",
"instances",
"remove-iam-policy-binding",
INSTANCE,
"--zone",
ZONE,
"--project",
PROJECT,
"--member",
member,
"--role",
os_role,
]
),
shell_join(
[
"gcloud",
"projects",
"remove-iam-policy-binding",
PROJECT,
"--member",
member,
"--role",
CUSTOM_ROLE,
]
),
shell_join(
[
"gcloud",
"projects",
"remove-iam-policy-binding",
PROJECT,
"--member",
member,
"--role",
IAP_ROLE,
"--condition",
f"expression={IAP_CONDITION},title=teleo-prod-1-ssh-only",
]
),
shell_join(
[
"gcloud",
"iam",
"service-accounts",
"remove-iam-policy-binding",
VM_SERVICE_ACCOUNT,
"--project",
PROJECT,
"--member",
member,
"--role",
"roles/iam.serviceAccountUser",
]
),
shell_join(
[
"gcloud",
"iam",
"service-accounts",
"delete",
account,
"--project",
PROJECT,
"--quiet",
]
),
]
)
commands.extend(
[
shell_join(
[
"gcloud",
"iam",
"workload-identity-pools",
"providers",
"delete",
PROVIDER,
"--location",
"global",
"--workload-identity-pool",
POOL,
"--project",
PROJECT,
"--quiet",
]
),
shell_join(
[
"gcloud",
"iam",
"roles",
"delete",
CUSTOM_ROLE_ID,
"--project",
PROJECT,
"--quiet",
]
),
]
)
return commands
def build_plan() -> dict[str, object]:
return {
"artifact": "teleo_gcp_iap_operator_access_plan",
"mode": "dry_run_emit_only",
"executes_commands": False,
"project": PROJECT,
"target": {
"instance": INSTANCE,
"zone": ZONE,
"internal_ip": INSTANCE_INTERNAL_IP,
"network": NETWORK,
"target_tag": TARGET_TAG,
},
"github": {
"repository": GITHUB_REPOSITORY,
"ref": "refs/heads/main",
"workflow_ref": WORKFLOW_REF,
"event_name": "workflow_dispatch",
},
"wif": {
"provider": PROVIDER_RESOURCE,
"provider_condition": PROVIDER_CONDITION,
"attribute_mapping": ATTRIBUTE_MAPPING,
"principal": WIF_PRINCIPAL,
"long_lived_google_credentials": False,
},
"identities": {
"status": {
"service_account": STATUS_SERVICE_ACCOUNT,
"os_login_role": "roles/compute.osLogin",
"sudo": False,
"operations": ["status"],
},
"clone_operator": {
"service_account": CLONE_SERVICE_ACCOUNT,
"os_login_role": "roles/compute.osAdminLogin",
"sudo": True,
"operations": ["direct-claim-replay", "cleanup-clone"],
"blast_radius": (
"OS Admin can become root on teleo-prod-1. The identity is separated from status, "
"the provider is pinned to the main workflow_dispatch workflow, and the checked-in "
"workflow invokes only the root-owned fixed-operation dispatcher. Branch protection "
"and workflow review remain required controls because OIDC does not attest file content."
),
},
},
"gcloud_ssh_custom_role": {
"role": CUSTOM_ROLE,
"permissions": CUSTOM_PERMISSIONS,
"binding_scope": f"projects/{PROJECT}",
"rationale": (
"The official gcloud IAP SSH permission table requires instance get/list plus project lookup. "
"This project-level role is read-only; exact destination authorization remains restricted by "
"the IAP destination condition and instance-level OS Login binding."
),
},
"iap": {
"role": IAP_ROLE,
"condition": IAP_CONDITION,
"firewall": {
"rule": IAP_FIREWALL_RULE,
"source_range": IAP_SOURCE_RANGE,
"protocol_ports": "tcp:22",
"target_tag": TARGET_TAG,
},
},
"service_account_user": {
"target_service_account": VM_SERVICE_ACCOUNT,
"role": "roles/iam.serviceAccountUser",
"rationale": "Required to connect to a VM that runs as the dedicated VM service account.",
},
"explicitly_absent": {
"roles": FORBIDDEN_OPERATOR_ROLES,
"permissions": [
"compute.instances.setMetadata",
"compute.projects.setCommonInstanceMetadata",
"secretmanager.versions.access",
"cloudsql.instances.update",
"artifactregistry.repositories.uploadArtifacts",
],
"credentials": [
"Google password",
"local user refresh token",
"service-account JSON key",
"API key",
],
},
"bootstrap_commands": bootstrap_commands(),
"post_bootstrap_verification": status_verification_commands(),
"public_ingress_cutover": {
"old_rule": CHANGING_PUBLIC_RULE,
"disable_only_after": "successful workflow status artifact independently inspected",
"automatic_fallback_to_public_ssh": False,
},
"audit_logging": {
"guidance": (
"Before enabling the operator workflow, merge project IAM auditConfigs rather than replacing "
"the policy. Enable DATA_READ for iap.googleapis.com and DATA_WRITE for iap.googleapis.com, "
"plus ADMIN_READ for "
"compute.googleapis.com and iam.googleapis.com; retain IAP TCP tunnel and OS Login audit "
"entries, firewall-rule logs, "
"GitHub run ID, request_id, target_db, and sanitized result SHA-256. Never log auth outputs."
),
"readback_commands": [
f"gcloud projects get-iam-policy {PROJECT} --format=json",
(
"gcloud logging read "
'\'protoPayload.serviceName="iap.googleapis.com" AND '
f'resource.labels.project_id="{PROJECT}"\' --project {PROJECT} --limit 20 --format=json'
),
(
"gcloud logging read "
f'\'resource.type="gce_firewall_rule" AND resource.labels.firewall_rule_id="{IAP_FIREWALL_RULE}"\' '
f"--project {PROJECT} --limit 20 --format=json"
),
],
},
"independent_verification": [
"Use an auditor identity that cannot impersonate either operator service account.",
"Confirm the provider condition, both service-account policies, instance IAM policy, IAP condition, and firewall source/tag.",
"Dispatch status from gh on main, inspect the sanitized artifact, and confirm no public /32 rule was disabled before that pass.",
"Confirm the workflow created no service-account key and the runner removed its five-minute SSH key and auth credential file.",
],
"revocation_commands": revocation_commands(),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--format", choices=("json", "shell"), default="json")
args = parser.parse_args()
plan = build_plan()
if args.format == "shell":
print("# DRY RUN: review these commands; this program never executes them.")
for section in ("bootstrap_commands", "post_bootstrap_verification", "revocation_commands"):
print(f"\n# {section}")
for command in plan[section]:
print(command)
else:
print(json.dumps(plan, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())