diff --git a/tools/apply-backup-bucket-policy.sh b/tools/apply-backup-bucket-policy.sh new file mode 100755 index 0000000..58c7337 --- /dev/null +++ b/tools/apply-backup-bucket-policy.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Apply a prefix-scoped bucket policy for the resource-control backup key. +# Reads APPLICATION_ID from OpenBao. Never prints secret values. +# Uses the bootstrap key to PutBucketPolicy (org owner can always edit policies). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BAO_ADDR="${BAO_ADDR:-${VAULT_ADDR:-https://bao.coulomb.social}}" +REGION="nl-ams" +BUCKET="${BUCKET:-railiance-platform-pg-backup}" +PREFIX="${PREFIX:-platform-pg/}" + +need() { command -v "$1" >/dev/null || { echo "missing $1" >&2; exit 2; }; } +export PATH="${HOME}/.local/bin:${PATH}" +need python3 +need curl + +TOKEN="${OPENBAO_TOKEN:-${VAULT_TOKEN:-}}" +if [[ -z "$TOKEN" && -f "$HOME/.vault-token" ]]; then + TOKEN="$(cat "$HOME/.vault-token")" +fi +[[ -n "$TOKEN" ]] || { echo "no OpenBao token" >&2; exit 2; } + +read_kv() { + local path="$1" + curl -fsS -H "X-Vault-Token: $TOKEN" \ + "$BAO_ADDR/v1/platform/data/${path#platform/}" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(json.dumps(d["data"]["data"]))' +} + +BACKUP_JSON="$(read_kv workloads/railiance/backup/object-storage)" +BOOTSTRAP_JSON="$(read_kv workloads/railiance/scaleway/bootstrap)" + +EXPORTS="$( +BACKUP_JSON="$BACKUP_JSON" BOOTSTRAP_JSON="$BOOTSTRAP_JSON" python3 - <<'PY' +import json, os, sys, re +backup = json.loads(os.environ["BACKUP_JSON"]) +boot = json.loads(os.environ["BOOTSTRAP_JSON"]) +app = (backup.get("APPLICATION_ID") or "").strip() +if not app or app in {"xxx", "redacted", "changeme"}: + sys.stderr.write( + "APPLICATION_ID missing on platform/workloads/railiance/backup/object-storage\n" + "Founder: add that field in the OpenBao UI (IAM application resource-control UUID).\n" + "It is not a secret. Then re-run this script.\n" + ) + sys.exit(6) +if not re.fullmatch(r"[0-9a-fA-F-]{36}", app): + sys.stderr.write("APPLICATION_ID is not a 36-char UUID\n") + sys.exit(7) +def g(data, *ks): + for k in ks: + if data.get(k): + return data[k] + return "" +print("export APPLICATION_ID=" + json.dumps(app)) +print("export SCW_ACCESS_KEY=" + json.dumps(g(boot, "ACCESS_KEY"))) +print("export SCW_SECRET_KEY=" + json.dumps(g(boot, "SECRET_KEY"))) +print("export BACKUP_ACCESS_KEY=" + json.dumps(g(backup, "ACCESS_KEY", "ACCESS_KEY_ID"))) +print("export BACKUP_SECRET_KEY=" + json.dumps(g(backup, "SECRET_KEY", "ACCESS_SECRET_KEY"))) +PY +)" +eval "$EXPORTS" + +export SCW_DEFAULT_REGION="$REGION" +export AWS_ACCESS_KEY_ID="$SCW_ACCESS_KEY" +export AWS_SECRET_ACCESS_KEY="$SCW_SECRET_KEY" + +VENV="${TMPDIR:-/tmp}/reef-storage-boto3" +if [[ ! -x "$VENV/bin/python" ]]; then + python3 -m venv "$VENV" + "$VENV/bin/pip" -q install boto3 +fi + +echo "putting prefix-scoped bucket policy on $BUCKET (application id not printed)" +BUCKET="$BUCKET" PREFIX="$PREFIX" APPLICATION_ID="$APPLICATION_ID" \ + "$VENV/bin/python" - <<'PY' +import json, os +import boto3 +bucket = os.environ["BUCKET"] +prefix = os.environ["PREFIX"] +app = os.environ["APPLICATION_ID"] +policy = { + "Version": "2023-04-17", + "Id": "resource-control-scoped-backup", + "Statement": [ + { + "Sid": "BackupListPrefix", + "Effect": "Allow", + "Principal": {"SCW": f"application_id:{app}"}, + "Action": [ + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:ListBucketVersions", + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:GetLifecycleConfiguration", + ], + "Resource": [bucket], + "Condition": {"StringLike": {"s3:prefix": [prefix, prefix + "*"]}}, + }, + { + "Sid": "BackupObjectsUnderPrefix", + "Effect": "Allow", + "Principal": {"SCW": f"application_id:{app}"}, + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", + ], + "Resource": [f"{bucket}/{prefix}*"], + }, + ], +} +client = boto3.client( + "s3", + region_name="nl-ams", + endpoint_url="https://s3.nl-ams.scw.cloud", + aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"], +) +client.put_bucket_policy(Bucket=bucket, Policy=json.dumps(policy)) +print("bucket_policy=put") +got = client.get_bucket_policy(Bucket=bucket) +body = json.loads(got["Policy"]) +print("statements", [s.get("Sid") for s in body.get("Statement", [])]) +PY + +echo "verifying backup key: prefix ok, sibling denied" +BUCKET="$BUCKET" PREFIX="$PREFIX" \ +AWS_ACCESS_KEY_ID="$BACKUP_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$BACKUP_SECRET_KEY" \ + "$VENV/bin/python" - <<'PY' +import os +from botocore.exceptions import ClientError +import boto3 +c = boto3.client( + "s3", + region_name="nl-ams", + endpoint_url="https://s3.nl-ams.scw.cloud", + aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"], +) +bucket = os.environ["BUCKET"] +prefix = os.environ["PREFIX"] +ok = prefix + ".policy-probe" +sib = "other-consumer/.policy-probe" +try: + c.put_object(Bucket=bucket, Key=ok, Body=b"ok") + c.delete_object(Bucket=bucket, Key=ok) + print("prefix_write=ok") +except ClientError as e: + print("prefix_write=fail", e.response.get("Error", {}).get("Code")) +try: + c.put_object(Bucket=bucket, Key=sib, Body=b"no") + print("sibling_write=UNEXPECTED_OK") + c.delete_object(Bucket=bucket, Key=sib) +except ClientError as e: + print("sibling_write=denied", e.response.get("Error", {}).get("Code")) +PY + +echo "done" +unset SCW_SECRET_KEY SCW_ACCESS_KEY BACKUP_SECRET_KEY BACKUP_ACCESS_KEY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY APPLICATION_ID