This commit is contained in:
parent
04e586c23e
commit
8f96b44539
7 changed files with 357 additions and 4 deletions
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -42,6 +42,7 @@ jobs:
|
|||
lib/llm.py \
|
||||
lib/post_extract.py \
|
||||
ops/check_gcp_infra_readiness.py \
|
||||
ops/plan_gcp_iam_split.py \
|
||||
ops/sqlite_to_postgres_dump.py \
|
||||
telegram/approvals.py \
|
||||
scripts/check_crabbox_ci_contract.py \
|
||||
|
|
@ -52,6 +53,8 @@ jobs:
|
|||
tests/test_decision_engine_replay.py \
|
||||
tests/test_evaluate_agent_routing.py \
|
||||
tests/test_gcp_cloudsql_restore_drill.py \
|
||||
tests/test_gcp_iam_split_plan.py \
|
||||
tests/test_gcp_readiness_workflow.py \
|
||||
tests/test_phase1b_end_to_end.py \
|
||||
tests/test_sqlite_to_postgres_dump.py \
|
||||
tests/test_eval_parse.py \
|
||||
|
|
|
|||
11
.github/workflows/gcp-readiness.yml
vendored
11
.github/workflows/gcp-readiness.yml
vendored
|
|
@ -2,6 +2,11 @@ name: gcp-readiness
|
|||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service_account:
|
||||
description: GCP service account to impersonate for the read-only readiness probe
|
||||
required: false
|
||||
default: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -14,7 +19,7 @@ concurrency:
|
|||
env:
|
||||
PROJECT_ID: teleo-501523
|
||||
WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
ARTIFACT_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com
|
||||
READINESS_SERVICE_ACCOUNT: ${{ inputs.service_account || 'sa-artifact-builder@teleo-501523.iam.gserviceaccount.com' }}
|
||||
READINESS_ARTIFACT_DIR: gcp-readiness-artifacts
|
||||
|
||||
jobs:
|
||||
|
|
@ -29,7 +34,7 @@ jobs:
|
|||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.ARTIFACT_SERVICE_ACCOUNT }}
|
||||
service_account: ${{ env.READINESS_SERVICE_ACCOUNT }}
|
||||
|
||||
- uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
|
|
@ -40,7 +45,7 @@ jobs:
|
|||
mkdir -p "${READINESS_ARTIFACT_DIR}"
|
||||
{
|
||||
echo "project=${PROJECT_ID}"
|
||||
echo "service_account=${ARTIFACT_SERVICE_ACCOUNT}"
|
||||
echo "service_account=${READINESS_SERVICE_ACCOUNT}"
|
||||
echo "workflow_ref=${GITHUB_REF}"
|
||||
echo "revision=${GITHUB_SHA}"
|
||||
} > "${READINESS_ARTIFACT_DIR}/context.txt"
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ uploads stdout, stderr, exit code, and a summary as the `gcp-readiness` artifact
|
|||
It is intentionally non-mutating. If the Artifact Registry service account lacks
|
||||
broader read permissions, the artifact is still the proof of the exact IAM gap.
|
||||
|
||||
After applying the dedicated readiness service account from the IAM split plan,
|
||||
run the same workflow against that account:
|
||||
|
||||
```bash
|
||||
gh workflow run gcp-readiness.yml \
|
||||
--repo living-ip/teleo-infrastructure \
|
||||
--ref main \
|
||||
-f service_account=sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com
|
||||
```
|
||||
|
||||
For a local/manual Cloud Build proof:
|
||||
|
||||
```bash
|
||||
|
|
@ -127,6 +137,39 @@ be converted and restored into PostgreSQL with table/row-count parity, but the
|
|||
same import still needs to run against the GCP Cloud SQL standby through an
|
||||
approved GCP auth and network path.
|
||||
|
||||
## IAM Split Plan
|
||||
|
||||
Do not make `sa-artifact-builder@teleo-501523.iam.gserviceaccount.com` the broad
|
||||
infra account. Keep it scoped to Docker image publishing.
|
||||
|
||||
Use the generated plan for the next privilege boundary:
|
||||
|
||||
```bash
|
||||
python3 ops/plan_gcp_iam_split.py --format json
|
||||
python3 ops/plan_gcp_iam_split.py --format shell
|
||||
```
|
||||
|
||||
The plan creates two separate accounts:
|
||||
|
||||
- `sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com`
|
||||
- GitHub WIF account for read-only readiness checks.
|
||||
- Needs read-only roles for Artifact Registry, IAM/service-account/WIF
|
||||
metadata, Compute/network posture, Cloud SQL metadata, backup buckets, and
|
||||
Secret Manager metadata.
|
||||
- `sa-teleo-restore-drill@teleo-501523.iam.gserviceaccount.com`
|
||||
- Operator account for explicit Cloud SQL restore drills.
|
||||
- Needs Cloud SQL edit rights for the import operation and object-admin access
|
||||
to the restore bucket path.
|
||||
|
||||
Cloud SQL imports also require the Cloud SQL instance service account to read the
|
||||
GCS object. The plan includes a command that discovers that instance service
|
||||
account and grants it `roles/storage.objectAdmin` on
|
||||
`gs://teleo-501523-prod-backups`.
|
||||
|
||||
This plan is not itself a completed redundancy proof. DB redundancy is complete
|
||||
only after the restore drill imports source data into Cloud SQL and the retained
|
||||
`target-counts.sql` readback matches the source/local restore proof.
|
||||
|
||||
## Communication Posture
|
||||
|
||||
Current GCP communication hardening is limited to the surfaces already read back:
|
||||
|
|
|
|||
|
|
@ -267,9 +267,10 @@ def check_github_actions_readiness_ci() -> Check:
|
|||
text = workflow.read_text()
|
||||
required = [
|
||||
"workflow_dispatch",
|
||||
"service_account:",
|
||||
"google-github-actions/auth@v2",
|
||||
"ops/check_gcp_infra_readiness.py",
|
||||
GITHUB_ARTIFACT_SERVICE_ACCOUNT,
|
||||
"READINESS_SERVICE_ACCOUNT",
|
||||
"READINESS_ARTIFACT_DIR: gcp-readiness-artifacts",
|
||||
"gcp-readiness-summary.json",
|
||||
"actions/upload-artifact@v4",
|
||||
|
|
|
|||
260
ops/plan_gcp_iam_split.py
Normal file
260
ops/plan_gcp_iam_split.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Emit the least-privilege GCP IAM split for Teleo CI/CD and DB drills.
|
||||
|
||||
This script is intentionally dry-run by default. It does not read or print
|
||||
secret values; it only emits the service accounts, roles, and commands needed
|
||||
to separate Docker publishing from infra readiness and Cloud SQL restore drills.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shlex
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
PROJECT = "teleo-501523"
|
||||
PROJECT_NUMBER = "785938879453"
|
||||
GITHUB_REPOSITORY = "living-ip/teleo-infrastructure"
|
||||
WIF_POOL = "github-actions"
|
||||
WIF_PROVIDER = "living-ip-github"
|
||||
BACKUP_BUCKET = "teleo-501523-prod-backups"
|
||||
CLOUDSQL_INSTANCE = "teleo-pgvector-standby"
|
||||
|
||||
ARTIFACT_SERVICE_ACCOUNT = f"sa-artifact-builder@{PROJECT}.iam.gserviceaccount.com"
|
||||
READINESS_SERVICE_ACCOUNT_ID = "sa-teleo-readiness"
|
||||
READINESS_SERVICE_ACCOUNT = f"{READINESS_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
|
||||
RESTORE_SERVICE_ACCOUNT_ID = "sa-teleo-restore-drill"
|
||||
RESTORE_SERVICE_ACCOUNT = f"{RESTORE_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
|
||||
|
||||
READINESS_PROJECT_ROLES = [
|
||||
"roles/artifactregistry.reader",
|
||||
"roles/iam.serviceAccountViewer",
|
||||
"roles/iam.workloadIdentityPoolViewer",
|
||||
"roles/iam.securityReviewer",
|
||||
"roles/compute.viewer",
|
||||
"roles/cloudsql.viewer",
|
||||
"roles/storage.viewer",
|
||||
"roles/secretmanager.viewer",
|
||||
]
|
||||
|
||||
RESTORE_PROJECT_ROLES = [
|
||||
"roles/cloudsql.editor",
|
||||
]
|
||||
|
||||
RESTORE_BUCKET_ROLES = [
|
||||
"roles/storage.objectAdmin",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
scope: str
|
||||
member: str
|
||||
role: str
|
||||
reason: str
|
||||
|
||||
|
||||
def shell_join(parts: list[str]) -> str:
|
||||
return " ".join(shlex.quote(part) for part in parts)
|
||||
|
||||
|
||||
def github_wif_member() -> str:
|
||||
return (
|
||||
f"principalSet://iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/"
|
||||
f"workloadIdentityPools/{WIF_POOL}/attribute.repository/{GITHUB_REPOSITORY}"
|
||||
)
|
||||
|
||||
|
||||
def build_plan() -> dict[str, object]:
|
||||
readiness_bindings = [
|
||||
Binding(
|
||||
scope=f"projects/{PROJECT}",
|
||||
member=f"serviceAccount:{READINESS_SERVICE_ACCOUNT}",
|
||||
role=role,
|
||||
reason="read-only GitHub readiness probe",
|
||||
)
|
||||
for role in READINESS_PROJECT_ROLES
|
||||
]
|
||||
restore_bindings = [
|
||||
Binding(
|
||||
scope=f"projects/{PROJECT}",
|
||||
member=f"serviceAccount:{RESTORE_SERVICE_ACCOUNT}",
|
||||
role=role,
|
||||
reason="operator-triggered Cloud SQL import operation",
|
||||
)
|
||||
for role in RESTORE_PROJECT_ROLES
|
||||
]
|
||||
restore_bucket_bindings = [
|
||||
Binding(
|
||||
scope=f"gs://{BACKUP_BUCKET}",
|
||||
member=f"serviceAccount:{RESTORE_SERVICE_ACCOUNT}",
|
||||
role=role,
|
||||
reason="upload and verify generated restore SQL in the backup bucket",
|
||||
)
|
||||
for role in RESTORE_BUCKET_ROLES
|
||||
]
|
||||
|
||||
commands = [
|
||||
shell_join(
|
||||
[
|
||||
"gcloud",
|
||||
"iam",
|
||||
"service-accounts",
|
||||
"create",
|
||||
READINESS_SERVICE_ACCOUNT_ID,
|
||||
"--project",
|
||||
PROJECT,
|
||||
"--display-name",
|
||||
"Teleo read-only infra readiness",
|
||||
]
|
||||
),
|
||||
shell_join(
|
||||
[
|
||||
"gcloud",
|
||||
"iam",
|
||||
"service-accounts",
|
||||
"add-iam-policy-binding",
|
||||
READINESS_SERVICE_ACCOUNT,
|
||||
"--project",
|
||||
PROJECT,
|
||||
"--member",
|
||||
github_wif_member(),
|
||||
"--role",
|
||||
"roles/iam.workloadIdentityUser",
|
||||
]
|
||||
),
|
||||
*[
|
||||
shell_join(
|
||||
[
|
||||
"gcloud",
|
||||
"projects",
|
||||
"add-iam-policy-binding",
|
||||
PROJECT,
|
||||
"--member",
|
||||
binding.member,
|
||||
"--role",
|
||||
binding.role,
|
||||
]
|
||||
)
|
||||
for binding in readiness_bindings
|
||||
],
|
||||
shell_join(
|
||||
[
|
||||
"gcloud",
|
||||
"iam",
|
||||
"service-accounts",
|
||||
"create",
|
||||
RESTORE_SERVICE_ACCOUNT_ID,
|
||||
"--project",
|
||||
PROJECT,
|
||||
"--display-name",
|
||||
"Teleo Cloud SQL restore drill",
|
||||
]
|
||||
),
|
||||
*[
|
||||
shell_join(
|
||||
[
|
||||
"gcloud",
|
||||
"projects",
|
||||
"add-iam-policy-binding",
|
||||
PROJECT,
|
||||
"--member",
|
||||
binding.member,
|
||||
"--role",
|
||||
binding.role,
|
||||
]
|
||||
)
|
||||
for binding in restore_bindings
|
||||
],
|
||||
*[
|
||||
shell_join(
|
||||
[
|
||||
"gcloud",
|
||||
"storage",
|
||||
"buckets",
|
||||
"add-iam-policy-binding",
|
||||
binding.scope,
|
||||
"--member",
|
||||
binding.member,
|
||||
"--role",
|
||||
binding.role,
|
||||
]
|
||||
)
|
||||
for binding in restore_bucket_bindings
|
||||
],
|
||||
"CLOUDSQL_INSTANCE_SA=$(gcloud sql instances describe "
|
||||
+ shlex.quote(CLOUDSQL_INSTANCE)
|
||||
+ " --project "
|
||||
+ shlex.quote(PROJECT)
|
||||
+ " --format='value(serviceAccountEmailAddress)')",
|
||||
"gcloud storage buckets add-iam-policy-binding "
|
||||
+ shlex.quote(f"gs://{BACKUP_BUCKET}")
|
||||
+ ' --member "serviceAccount:${CLOUDSQL_INSTANCE_SA}" --role roles/storage.objectAdmin',
|
||||
shell_join(
|
||||
[
|
||||
"gh",
|
||||
"workflow",
|
||||
"run",
|
||||
"gcp-readiness.yml",
|
||||
"--repo",
|
||||
GITHUB_REPOSITORY,
|
||||
"--ref",
|
||||
"main",
|
||||
"-f",
|
||||
f"service_account={READINESS_SERVICE_ACCOUNT}",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
return {
|
||||
"artifact": "teleo_gcp_iam_split_plan",
|
||||
"project": PROJECT,
|
||||
"claim_ceiling": "dry_run_plan_only_until_applied_by_authenticated_gcp_admin",
|
||||
"kept_narrow": {
|
||||
"artifact_service_account": ARTIFACT_SERVICE_ACCOUNT,
|
||||
"reason": "Docker image publishing only; do not add broad infra-read or restore roles here.",
|
||||
},
|
||||
"readiness_service_account": READINESS_SERVICE_ACCOUNT,
|
||||
"restore_service_account": RESTORE_SERVICE_ACCOUNT,
|
||||
"github_wif_provider": f"projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{WIF_POOL}/providers/{WIF_PROVIDER}",
|
||||
"github_wif_member": github_wif_member(),
|
||||
"readiness_project_roles": READINESS_PROJECT_ROLES,
|
||||
"restore_project_roles": RESTORE_PROJECT_ROLES,
|
||||
"restore_bucket_roles": RESTORE_BUCKET_ROLES,
|
||||
"cloudsql_instance_service_account_binding": {
|
||||
"bucket": f"gs://{BACKUP_BUCKET}",
|
||||
"role": "roles/storage.objectAdmin",
|
||||
"reason": "Cloud SQL imports read the SQL object through the Cloud SQL instance service account.",
|
||||
},
|
||||
"bindings": {
|
||||
"readiness": [asdict(binding) for binding in readiness_bindings],
|
||||
"restore_operator": [asdict(binding) for binding in restore_bindings],
|
||||
"restore_bucket": [asdict(binding) for binding in restore_bucket_bindings],
|
||||
},
|
||||
"commands": commands,
|
||||
"post_apply_checks": [
|
||||
f"gh workflow run gcp-readiness.yml --repo {GITHUB_REPOSITORY} --ref main -f service_account={READINESS_SERVICE_ACCOUNT}",
|
||||
"EXECUTE=1 ops/run_gcp_cloudsql_restore_drill.sh",
|
||||
"retain target-counts.sql query readback before claiming GCP DB redundancy",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
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("# Dry-run command plan. Review before executing with an authenticated GCP admin.")
|
||||
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())
|
||||
39
tests/test_gcp_iam_split_plan.py
Normal file
39
tests/test_gcp_iam_split_plan.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_plan(*args: str) -> str:
|
||||
return subprocess.check_output(
|
||||
["python3", "ops/plan_gcp_iam_split.py", *args],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_gcp_iam_split_keeps_artifact_builder_narrow() -> None:
|
||||
plan = json.loads(run_plan())
|
||||
|
||||
assert plan["kept_narrow"]["artifact_service_account"] == (
|
||||
"sa-artifact-builder@teleo-501523.iam.gserviceaccount.com"
|
||||
)
|
||||
assert plan["readiness_service_account"] == "sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com"
|
||||
assert plan["restore_service_account"] == "sa-teleo-restore-drill@teleo-501523.iam.gserviceaccount.com"
|
||||
assert "roles/artifactregistry.writer" not in plan["readiness_project_roles"]
|
||||
assert "roles/cloudsql.editor" not in plan["readiness_project_roles"]
|
||||
assert "roles/compute.viewer" in plan["readiness_project_roles"]
|
||||
assert "roles/cloudsql.viewer" in plan["readiness_project_roles"]
|
||||
assert "roles/secretmanager.viewer" in plan["readiness_project_roles"]
|
||||
assert plan["restore_project_roles"] == ["roles/cloudsql.editor"]
|
||||
assert plan["restore_bucket_roles"] == ["roles/storage.objectAdmin"]
|
||||
|
||||
|
||||
def test_gcp_iam_split_shell_plan_has_post_apply_checks() -> None:
|
||||
shell = run_plan("--format", "shell")
|
||||
|
||||
assert "sa-teleo-readiness" in shell
|
||||
assert "sa-teleo-restore-drill" in shell
|
||||
assert "roles/iam.workloadIdentityUser" in shell
|
||||
assert "CLOUDSQL_INSTANCE_SA=$(gcloud sql instances describe" in shell
|
||||
assert '--member "serviceAccount:${CLOUDSQL_INSTANCE_SA}"' in shell
|
||||
assert "gh workflow run gcp-readiness.yml" in shell
|
||||
|
|
@ -4,6 +4,8 @@ from pathlib import Path
|
|||
def test_gcp_readiness_workflow_uses_uploadable_artifact_dir() -> None:
|
||||
workflow = Path(".github/workflows/gcp-readiness.yml").read_text()
|
||||
|
||||
assert "service_account:" in workflow
|
||||
assert "READINESS_SERVICE_ACCOUNT:" in workflow
|
||||
assert "READINESS_ARTIFACT_DIR: gcp-readiness-artifacts" in workflow
|
||||
assert 'mkdir -p "${READINESS_ARTIFACT_DIR}"' in workflow
|
||||
assert "path: ${{ env.READINESS_ARTIFACT_DIR }}/" in workflow
|
||||
|
|
|
|||
Loading…
Reference in a new issue