Repository navigation
ci: Add guarded step-5 bucket IAM removal workflow #515
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Pigbibi
wants to merge
1
commit into
main
Choose a base branch
from
ci/step5-bucket-iam-gha-r2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,252 @@ | ||
| name: FT step 5 bucket IAM removal | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry_run: | ||
| description: Validate only; false removes the two approved grants | ||
| required: true | ||
| type: boolean | ||
| default: true | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: ft-step5-bucket-iam | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| preflight: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 2 | ||
| steps: | ||
| - name: Require configured Environment and default branch | ||
| env: | ||
| IAM_ENVIRONMENT: ${{ vars.FIRSTRADE_STEP5_ENVIRONMENT }} | ||
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -z "$IAM_ENVIRONMENT" ] || [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then | ||
| echo 'Missing FIRSTRADE_STEP5_ENVIRONMENT or dispatch is not on the default branch.' | ||
| exit 1 | ||
| fi | ||
|
|
||
| remove-grants: | ||
| needs: preflight | ||
| environment: ${{ vars.FIRSTRADE_STEP5_ENVIRONMENT }} | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| permissions: | ||
| contents: read | ||
| id-token: write | ||
| env: | ||
| GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }} | ||
| GCP_WIF_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} | ||
| GCP_AUTH_SERVICE_ACCOUNT: ${{ secrets.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} | ||
| TARGET_BUCKET: ${{ secrets.FIRSTRADE_STEP5_BUCKET }} | ||
| TARGET_SERVICE_ACCOUNT: ${{ secrets.FIRSTRADE_STEP5_SERVICE_ACCOUNT }} | ||
| DRY_RUN: ${{ inputs.dry_run }} | ||
| steps: | ||
| - name: Validate Environment configuration and mask identifiers | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| python3 - <<'PYCONFIG' | ||
| import os | ||
| import re | ||
| import sys | ||
| names = ('GCP_PROJECT_ID', 'GCP_WIF_PROVIDER', 'GCP_AUTH_SERVICE_ACCOUNT', | ||
| 'TARGET_BUCKET', 'TARGET_SERVICE_ACCOUNT') | ||
| missing = [name for name in names if not os.environ.get(name)] | ||
| if missing: | ||
| print('Missing Environment configuration: ' + ', '.join(missing)) | ||
| sys.exit(1) | ||
| for name in names: | ||
| value = os.environ[name] | ||
| if any(c in value for c in '\r\n'): | ||
| print('Invalid Environment configuration') | ||
| sys.exit(1) | ||
| for part in [value, *re.split(r'[/@.:]', value)]: | ||
| if len(part) > 2: | ||
| print('::add-mask::' + part) | ||
| PYCONFIG | ||
| - name: Authenticate with Environment WIF credentials | ||
| id: auth | ||
| uses: google-github-actions/auth@v3 | ||
| with: | ||
| workload_identity_provider: ${{ env.GCP_WIF_PROVIDER }} | ||
| service_account: ${{ env.GCP_AUTH_SERVICE_ACCOUNT }} | ||
| create_credentials_file: false | ||
| token_format: access_token | ||
| - name: Set up gcloud | ||
| uses: google-github-actions/setup-gcloud@v3 | ||
| with: | ||
| project_id: ${{ env.GCP_PROJECT_ID }} | ||
| - name: Validate and optionally remove exactly two grants | ||
| env: | ||
| CLOUDSDK_AUTH_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| python3 - <<'PYIAM' | ||
| import copy | ||
| import json | ||
| import os | ||
| import re | ||
| import subprocess | ||
| import sys | ||
| import urllib.error | ||
| import urllib.parse | ||
| import urllib.request | ||
| from pathlib import Path | ||
|
|
||
| ROLES = ("roles/storage.objectCreator", "roles/storage.objectViewer") | ||
|
|
||
|
|
||
| def require(ok, message): | ||
| if not ok: | ||
| raise ValueError(message) | ||
|
|
||
|
|
||
| def mask(value): | ||
| # One shared serializer for policies and diagnostics; never emit raw strings. | ||
| if isinstance(value, str): | ||
| return "***" + value[-2:] | ||
| if isinstance(value, list): | ||
| return [mask(item) for item in value] | ||
| if isinstance(value, dict): | ||
| return {key: mask(item) for key, item in value.items()} | ||
| return value | ||
|
|
||
|
|
||
| def normalized(policy): | ||
| result = copy.deepcopy(policy) | ||
| result.pop("etag", None) | ||
| bindings = result.get("bindings", []) | ||
| for binding in bindings: | ||
| binding["members"] = sorted(binding["members"]) | ||
| result["bindings"] = sorted(bindings, key=lambda b: json.dumps(b, sort_keys=True)) | ||
| return result | ||
|
|
||
|
|
||
| def plan(before, member): | ||
| require(isinstance(before, dict), "Invalid policy") | ||
| require(set(before) <= {"bindings", "etag", "version"}, "Unknown policy fields") | ||
| require(isinstance(before.get("etag"), str) and before["etag"], "Missing etag") | ||
| require(before.get("version") in (1, 3), "Unknown policy version") | ||
| require(isinstance(before.get("bindings"), list), "Missing bindings") | ||
| after = copy.deepcopy(before) | ||
| counts = dict.fromkeys(ROLES, 0) | ||
| kept = [] | ||
| for binding in after["bindings"]: | ||
| require(isinstance(binding, dict), "Invalid binding") | ||
| require(set(binding) <= {"role", "members", "condition"}, "Unknown binding fields") | ||
| require(isinstance(binding.get("role"), str), "Missing role") | ||
| if "condition" in binding: | ||
| condition = binding["condition"] | ||
| require(isinstance(condition, dict) and set(condition) <= {"title", "description", "expression", "location"} | ||
| and all(isinstance(v, str) for v in condition.values()), "Invalid condition") | ||
| members = binding.get("members") | ||
| require(isinstance(members, list) and all(isinstance(m, str) for m in members), | ||
| "Invalid members") | ||
| require(len(members) == len(set(members)) and members, "Invalid duplicate/empty members") | ||
| role = binding["role"] | ||
| if role in ROLES and member in members: | ||
| require("condition" not in binding, "Conditional target binding requires review") | ||
| counts[role] += 1 | ||
| members.remove(member) | ||
| if not members: | ||
| continue | ||
| kept.append(binding) | ||
| require(all(count == 1 for count in counts.values()), "Expected exactly two target grants") | ||
| after["bindings"] = kept | ||
| # Independent reconstruction proves that no other policy content was edited. | ||
| restored = copy.deepcopy(after) | ||
| for role in ROLES: | ||
| matches = [b for b in restored["bindings"] if b["role"] == role and "condition" not in b] | ||
| original = [b for b in before["bindings"] if b["role"] == role and member in b["members"]][0] | ||
| if len(original["members"]) == 1: | ||
| restored["bindings"].append(copy.deepcopy(original)) | ||
| else: | ||
| expected = [m for m in original["members"] if m != member] | ||
| matches = [b for b in matches if b["members"] == expected] | ||
| require(len(matches) == 1, "Ambiguous target binding") | ||
| matches[0]["members"].append(member) | ||
| require(normalized(restored) == normalized(before), "Unexpected planned difference") | ||
| return after | ||
|
|
||
|
|
||
| def verify(expected, after): | ||
| require(isinstance(after.get("etag"), str) and after["etag"], "Missing readback etag") | ||
| require(normalized(expected) == normalized(after), "Unexpected policy readback difference") | ||
|
|
||
|
|
||
| def main(): | ||
| bucket = os.environ.get("TARGET_BUCKET", "") | ||
| account = os.environ.get("TARGET_SERVICE_ACCOUNT", "") | ||
| dry = os.environ.get("DRY_RUN", "") | ||
| require(re.fullmatch(r"[a-z0-9][a-z0-9._-]{1,220}[a-z0-9]", bucket), "Invalid bucket config") | ||
| require(re.fullmatch(r"[a-z0-9-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com", account), | ||
| "Invalid target service account config") | ||
| require(dry in ("true", "false"), "Invalid dry_run") | ||
| token_result = subprocess.run(["gcloud", "auth", "print-access-token"], capture_output=True, | ||
| text=True, check=False) | ||
| require(token_result.returncode == 0 and token_result.stdout.strip(), "Token acquisition failed") | ||
| token = token_result.stdout.strip() | ||
| url = "https://storage.googleapis.com/storage/v1/b/" + urllib.parse.quote(bucket, safe="") + "/iam" | ||
|
|
||
| def request(method, policy=None): | ||
| data = None if policy is None else json.dumps(policy).encode() | ||
| endpoint = url + ("?optionsRequestedPolicyVersion=3" if method == "GET" else "") | ||
| req = urllib.request.Request(endpoint, data=data, method=method, headers={ | ||
| "Authorization": "Bearer " + token, "Content-Type": "application/json"}) | ||
| try: | ||
| with urllib.request.urlopen(req, timeout=60) as response: | ||
| return json.load(response) | ||
| except urllib.error.HTTPError as error: | ||
| # Never echo the server body, URL, principal, token, or raw exception. | ||
| permission = "storage.buckets.setIamPolicy" if method == "PUT" else "storage.buckets.getIamPolicy" | ||
| print(f"IAM request failed: HTTP {error.code}; required permission: {permission}") | ||
| raise ValueError("IAM request rejected; no retry") from None | ||
| except (OSError, ValueError): | ||
| raise ValueError("IAM transport or response integrity failure; no retry") from None | ||
|
|
||
| member = "serviceAccount:" + account | ||
| before = request("GET") | ||
| expected = plan(before, member) | ||
| evidence = {"dry_run": dry == "true", "before": mask(before), "planned": mask(expected), | ||
| "removals": [{"role": role, "member": mask(member)} for role in ROLES], | ||
| "planned_diff": "EXACT_TWO_REMOVALS"} | ||
| directory = Path("evidence") | ||
| directory.mkdir(exist_ok=True) | ||
| (directory / "policy.json").write_text(json.dumps(evidence, indent=2) + "\n") | ||
| print(json.dumps(evidence)) | ||
| if dry == "false": | ||
| # The original etag is included in PUT. A competing update must fail, never retry. | ||
| response = request("PUT", expected) | ||
| verify(expected, response) | ||
| after = request("GET") | ||
| evidence["after"] = mask(after) | ||
| (directory / "policy.json").write_text(json.dumps(evidence, indent=2) + "\n") | ||
| verify(expected, after) | ||
| evidence["applied_diff"] = "EXACT_TWO_REMOVALS" | ||
| (directory / "policy.json").write_text(json.dumps(evidence, indent=2) + "\n") | ||
| print(json.dumps({"after": mask(after), "applied_diff": evidence["applied_diff"]})) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| try: | ||
| main() | ||
| except Exception: | ||
| print("FAIL CLOSED: IAM operation or integrity check failed; inspect masked evidence.") | ||
| sys.exit(1) | ||
| PYIAM | ||
| - name: Upload masked policy evidence | ||
| if: ${{ always() }} | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: step5-masked-policy | ||
| path: evidence/*.json | ||
| if-no-files-found: ignore | ||
| retention-days: 7 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The workflow reads the Cloud Storage JSON API directly, whose documented
getIamPolicyresponse includeskind: "storage#policy"andresourceIdalongside the allowed fields (API reference). This allowlist therefore rejects a valid bucket policy withUnknown policy fields, preventing both dry-run evidence generation and grant removal even with correct Environment configuration. I reproduced this using the extractedplan()function and a synthetic response matching the documented schema. Accept and validate these metadata fields, or consistently strip them before planning and readback comparison.Useful? React with 👍 / 👎.