Commit 9347bc59 authored by xuwang's avatar xuwang
Browse files

Factor iap-authz into gke-iap-authz.sh with IAP_MEMBERS validation



Move the iap-authz/list/revoke logic from gke-iap-authz.mk into a single
gke-iap-authz.sh <set|list|revoke> script; the makefile targets are now thin
wrappers. Add pre-flight IAP_MEMBERS validation: strict type:email/domain format
(hard fail) plus a best-effort existence check of Google groups/service accounts
(warns when the API/permission is unavailable, errors on a definitive not-found).

Changelog: added
Co-Authored-By: default avatarClaude Opus 4.8 <noreply@anthropic.com>
parent e557c951
Loading
Loading
Loading
Loading
+9 −30
Original line number Diff line number Diff line
@@ -32,48 +32,27 @@ ifndef APP_NAMESPACE
endif

# IAP access role and the ingress to read backend service names from. Defaults
# follow OTICA conventions; override per project if needed.
# follow OTICA conventions; override per project if needed. (The script applies
# the same defaults; these make them visible/overridable from make too.)
IAP_ROLE ?= roles/iap.httpsResourceAccessor
IAP_INGRESS ?= ${APP}-ing

# Resolve the GKE-generated backend service name(s) for IAP_INGRESS from its
# live status annotation. Emits one backend service name per line.
define _iap_backends
kubectl get ingress ${IAP_INGRESS} -n ${APP_NAMESPACE} -o json | python3 -c 'import sys,json;a=json.load(sys.stdin)["metadata"]["annotations"].get("ingress.kubernetes.io/backends","{}");print("\n".join(sorted(json.loads(a))))'
endef
# All logic lives in gke-iap-authz.sh (on PATH via OTICA_SCRIPTS_DIR); these
# targets are thin wrappers. The script reads APP_NAMESPACE, GCP_PROJECT_ID,
# IAP_MEMBERS, IAP_ROLE, IAP_INGRESS, IAP_VERIFY_MEMBERS from the environment
# (exported by the consumer makefile).

.PHONY: iap-authz
iap-authz: kc-config ## set IAP access to exactly IAP_MEMBERS on this app's backend service(s)
	@if [ -z "${IAP_MEMBERS}" ]; then echo "IAP_MEMBERS is empty; set it in env.mk (use iap-authz-revoke to remove access)"; exit 1; fi
	@backends=$$($(_iap_backends)); \
	if [ -z "$$backends" ]; then echo "No backend services for ${IAP_INGRESS} (is the ingress synced?)"; exit 1; fi; \
	for bs in $$backends; do \
		echo "reconcile ${IAP_ROLE} on $$bs -> [${IAP_MEMBERS}]"; \
		tmp=$$(mktemp); \
		gcloud iap web get-iam-policy --resource-type=backend-services --service=$$bs --project=${GCP_PROJECT_ID} --format=json \
		  | IAP_MEMBERS="${IAP_MEMBERS}" IAP_ROLE="${IAP_ROLE}" python3 -c 'import sys,json,os;p=json.load(sys.stdin);r=os.environ["IAP_ROLE"];m=sorted(set(os.environ["IAP_MEMBERS"].split()));p["bindings"]=[b for b in p.get("bindings",[]) if b.get("role")!=r]+([{"role":r,"members":m}] if m else []);json.dump(p,sys.stdout)' > $$tmp; \
		gcloud iap web set-iam-policy $$tmp --resource-type=backend-services --service=$$bs --project=${GCP_PROJECT_ID} --quiet >/dev/null; \
		rm -f $$tmp; \
	done
	@gke-iap-authz.sh set

.PHONY: iap-authz-list
iap-authz-list: kc-config ## show IAP IAM policy for this app's backend service(s)
	@backends=$$($(_iap_backends)); \
	if [ -z "$$backends" ]; then echo "No backend services for ${IAP_INGRESS} (is the ingress synced?)"; exit 1; fi; \
	for bs in $$backends; do echo "== $$bs =="; \
		gcloud iap web get-iam-policy --resource-type=backend-services --service=$$bs --project=${GCP_PROJECT_ID}; \
	done
	@gke-iap-authz.sh list

.PHONY: iap-authz-revoke
iap-authz-revoke: kc-config ## revoke IAP access (IAP_MEMBERS) from this app's backend service(s)
	@if [ -z "${IAP_MEMBERS}" ]; then echo "IAP_MEMBERS is empty; nothing to revoke"; exit 1; fi
	@backends=$$($(_iap_backends)); \
	if [ -z "$$backends" ]; then echo "No backend services for ${IAP_INGRESS} (is the ingress synced?)"; exit 1; fi; \
	for bs in $$backends; do for m in ${IAP_MEMBERS}; do \
		echo "revoke $$m -> $$bs"; \
		gcloud iap web remove-iam-policy-binding --resource-type=backend-services --service=$$bs \
			--member="$$m" --role=${IAP_ROLE} --project=${GCP_PROJECT_ID} --quiet; \
	done; done
	@gke-iap-authz.sh revoke

# End of gke-iap-authz.mk
endif # GKE_IAP_AUTHZ_MK_INCLUDED
+153 −0
Original line number Diff line number Diff line
#!/bin/bash -e

###############################################################################
# gke-iap-authz.sh — manage per-app IAP authorization on a GKE Ingress backend.
#
# Usage: gke-iap-authz.sh <set|list|revoke>
#   set     reconcile the IAP_ROLE members to EXACTLY IAP_MEMBERS (validates
#           IAP_MEMBERS first); preserves the policy etag and other bindings
#   list    show the IAP IAM policy for the app's backend service(s)
#   revoke  remove the IAP_MEMBERS members from IAP_ROLE
#
# The backend service name is GKE-generated/per-cluster, so it is resolved from
# the live Ingress annotation (ingress.kubernetes.io/backends). The caller is
# expected to have selected the right cluster (kube context) first.
#
# Env:
#   APP_NAMESPACE     (required) k8s namespace of the ingress
#   GCP_PROJECT_ID    (required) project owning the backend service
#   IAP_MEMBERS       space-separated IAM members (required for set/revoke)
#   IAP_INGRESS       ingress name           (default: ${APP}-ing)
#   IAP_ROLE          IAP access role        (default: roles/iap.httpsResourceAccessor)
#   IAP_VERIFY_MEMBERS  best-effort existence check of members (default: true)
###############################################################################

action="${1:-}"

err() { echo; echo "ERROR: $*" >&2; echo; exit 1; }

case "${action}" in
  set|list|revoke) ;;
  *) err "usage: $(basename "$0") <set|list|revoke>" ;;
esac

[ -n "${APP_NAMESPACE:-}" ]  || err "APP_NAMESPACE is not set"
[ -n "${GCP_PROJECT_ID:-}" ] || err "GCP_PROJECT_ID is not set"

IAP_ROLE="${IAP_ROLE:-roles/iap.httpsResourceAccessor}"
IAP_VERIFY_MEMBERS="${IAP_VERIFY_MEMBERS:-true}"

if [ -z "${IAP_INGRESS:-}" ]; then
  [ -n "${APP:-}" ] || err "APP or IAP_INGRESS must be set"
  IAP_INGRESS="${APP}-ing"
fi

# --- resolve backend service name(s) from the live ingress annotation ---------
resolve_backends() {
  kubectl get ingress "${IAP_INGRESS}" -n "${APP_NAMESPACE}" -o json \
    | python3 -c 'import sys,json;a=json.load(sys.stdin)["metadata"]["annotations"].get("ingress.kubernetes.io/backends","{}");print("\n".join(sorted(json.loads(a))))'
}

# --- validate IAP_MEMBERS syntax: <type>:<email|domain> -----------------------
validate_members_format() {
  IAP_MEMBERS="${IAP_MEMBERS}" python3 - <<'PY'
import os, re, sys
members = os.environ.get("IAP_MEMBERS", "").split()
if not members:
    sys.exit("IAP_MEMBERS is empty")
email  = re.compile(r'^[^@\s]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
domain = re.compile(r'^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
bad = []
for m in members:
    if ":" not in m:
        bad.append(f"{m}  (missing type prefix, e.g. user:/group:/serviceAccount:/domain:)")
        continue
    t, v = m.split(":", 1)
    if t in ("user", "group", "serviceAccount"):
        if not email.match(v):
            bad.append(f"{m}  ({t} must be a valid email address)")
    elif t == "domain":
        if not domain.match(v):
            bad.append(f"{m}  (invalid domain)")
    else:
        bad.append(f"{m}  (unknown type '{t}'; use user|group|serviceAccount|domain)")
if bad:
    sys.exit("invalid IAP_MEMBERS:\n  " + "\n  ".join(bad))
PY
}

# --- best-effort existence check (real Google accounts) -----------------------
# A definitive "not found" is an error; inability to verify (API disabled /
# missing permission / unsupported type) is a non-fatal warning.
verify_members_exist() {
  local m t v out
  for m in ${IAP_MEMBERS}; do
    t="${m%%:*}"; v="${m#*:}"
    case "${t}" in
      group)
        if out=$(gcloud identity groups describe "${v}" --format='value(name)' 2>&1); then :
        elif echo "${out}" | grep -qiE 'not.?found|does not exist|Requested entity was not found'; then
          err "group does not exist: ${v}"
        else
          echo "WARN: could not verify group '${v}' (Cloud Identity API/permission?) — skipping check" >&2
        fi ;;
      serviceAccount)
        if out=$(gcloud iam service-accounts describe "${v}" --format='value(email)' 2>&1); then :
        elif echo "${out}" | grep -qiE 'not.?found|does not exist'; then
          err "service account does not exist: ${v}"
        else
          echo "WARN: could not verify service account '${v}' — skipping check" >&2
        fi ;;
      user)
        echo "INFO: user existence not verified (needs Directory API): ${v}" >&2 ;;
      domain)
        : ;;
    esac
  done
}

# --- pre-flight validation (no cluster access needed) -------------------------
case "${action}" in
  set)
    [ -n "${IAP_MEMBERS:-}" ] || err "IAP_MEMBERS is empty; set it in env.mk (use '$(basename "$0") revoke' to remove access)"
    validate_members_format
    [ "${IAP_VERIFY_MEMBERS}" = "true" ] && verify_members_exist
    ;;
  revoke)
    [ -n "${IAP_MEMBERS:-}" ] || err "IAP_MEMBERS is empty; nothing to revoke"
    ;;
esac

backends="$(resolve_backends)"
[ -n "${backends}" ] || err "no backend services for ingress '${IAP_INGRESS}' in namespace '${APP_NAMESPACE}' (is the ingress synced?)"

case "${action}" in
  list)
    for bs in ${backends}; do
      echo "== ${bs} =="
      gcloud iap web get-iam-policy --resource-type=backend-services --service="${bs}" --project="${GCP_PROJECT_ID}"
    done
    ;;

  set)
    for bs in ${backends}; do
      echo "reconcile ${IAP_ROLE} on ${bs} -> [${IAP_MEMBERS}]"
      tmp="$(mktemp)"
      trap 'rm -f "${tmp}"' EXIT
      gcloud iap web get-iam-policy --resource-type=backend-services --service="${bs}" --project="${GCP_PROJECT_ID}" --format=json \
        | IAP_MEMBERS="${IAP_MEMBERS}" IAP_ROLE="${IAP_ROLE}" python3 -c 'import sys,json,os;p=json.load(sys.stdin);r=os.environ["IAP_ROLE"];m=sorted(set(os.environ["IAP_MEMBERS"].split()));p["bindings"]=[b for b in p.get("bindings",[]) if b.get("role")!=r]+([{"role":r,"members":m}] if m else []);json.dump(p,sys.stdout)' > "${tmp}"
      gcloud iap web set-iam-policy "${tmp}" --resource-type=backend-services --service="${bs}" --project="${GCP_PROJECT_ID}" --quiet >/dev/null
      rm -f "${tmp}"
    done
    ;;

  revoke)
    for bs in ${backends}; do
      for m in ${IAP_MEMBERS}; do
        echo "revoke ${m} -> ${bs}"
        gcloud iap web remove-iam-policy-binding --resource-type=backend-services --service="${bs}" \
          --member="${m}" --role="${IAP_ROLE}" --project="${GCP_PROJECT_ID}" --quiet
      done
    done
    ;;
esac