Commit 46c0b211 authored by xuwang's avatar xuwang
Browse files

Add gke-iap-authz.mk: per-app GKE IAP authorization



Authoritative iap-authz target (plus iap-authz-list / iap-authz-revoke) that
manages roles/iap.httpsResourceAccessor on an app's GKE-generated backend
service, resolved from the live Ingress annotation and driven by IAP_MEMBERS.
Use instead of a project-wide google_iap_web_iam_binding for per-app access.
Adds docs/gke-iap-auth.md.

Changelog: added
Co-Authored-By: default avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 027f9cae
Loading
Loading
Loading
Loading

docs/gke-iap-auth.md

0 → 100644
+153 −0
Original line number Diff line number Diff line
# GKE IAP Authentication & Authorization

How apps behind a GKE Ingress are protected with Google Cloud **Identity-Aware
Proxy (IAP)**: the load balancer authenticates every request (Google SSO) and
authorization is granted **per app** on its backend service.

This is the OTICA framework reference for the [`gke-iap-authz.mk`](../makefiles/gke-iap-authz.mk)
module. A working consumer implementation lives in the `som-dom-brite` project
(`sub-projects/kube-precisionmds`): the `backend-config.yml`, `svc.yml`,
`ingress.yml` templates and `IAP_MEMBERS` in `env.mk`.

---

## How it works

```
client ──HTTPS──> GCLB (Ingress) ──> IAP ──> backend service ──> Service ──> Pod

                       authn: Google SSO (managed OAuth client)
                       authz: roles/iap.httpsResourceAccessor on the backend service
```

1. **Ingress** creates the external HTTPS load balancer.
2. The **Service** is annotated `cloud.google.com/backend-config` → it ties a
   **BackendConfig** to the GCLB backend service.
3. The **BackendConfig** turns IAP on (`spec.iap.enabled: true`). IAP then
   authenticates the user via Google and injects identity headers
   (`X-Goog-Authenticated-User-Email`, `-Id`, `X-Goog-IAP-JWT-Assertion`).
4. A user reaches the app only if they hold `roles/iap.httpsResourceAccessor`
   on that backend service.

> There is **no IAP setting on the Ingress object** in GKE — IAP lives on the
> BackendConfig referenced by the Service. "Adding IAP to the ingress" means
> wiring it through this chain.

GCP load-balancer **health checks bypass IAP**, so `/healthz`-style probes keep
working with no auth.

## Authentication — Google-managed OAuth client

IAP is enabled with a **Google-managed OAuth client**: the BackendConfig sets
`iap.enabled: true` with **no `oauthclientCredentials`**, and IAP provisions and
rotates the OAuth client itself under the project's IAP brand.

```yaml
# backend-config.yml
spec:
  iap:
    enabled: true        # managed client — no oauthclientCredentials
```

No custom OAuth client, no Vault secret, and no per-namespace K8s Secret to
maintain. A managed backend shows `oauth2ClientId: ''` (empty) and the IAP
sign-in redirect uses Google's IAP project client (`369001918367-…`).

## Authorization — per app, via `make iap-authz`

Authorization is **not** a project-wide binding. GCP IAM is *additive*, so a
project-level `google_iap_web_iam_binding` grant is inherited by every IAP
backend and **cannot be narrowed per app**. Instead, each app's allowed members
are granted on **its own backend service**.

Because the GKE backend service name is dynamic/per-cluster,
[`gke-iap-authz.mk`](../makefiles/gke-iap-authz.mk) resolves it from the live
Ingress annotation (`ingress.kubernetes.io/backends`) — `kc-config` pins the
cluster, so stage/prod auto-disambiguate.

| Target | Action |
|---|---|
| `make iap-authz` | **Authoritative** — sets the role's members to *exactly* `IAP_MEMBERS` (adds missing, removes dropped). Preserves the policy etag and any other role bindings. |
| `make iap-authz-list` | Show the current IAP IAM policy for the app's backend service(s). |
| `make iap-authz-revoke` | Remove the `IAP_MEMBERS` members (one-off). |

`IAP_MEMBERS` (space-separated IAM members) in the app's `env.mk` is the single
source of truth:

```makefile
# env.mk
IAP_MEMBERS=group:${GCP_ADMIN_GROUP} group:${PROJECT_DEV_GROUP}
```

Edit the list, run `make iap-authz`, and the live policy matches. An **empty**
`IAP_MEMBERS` is rejected by `iap-authz` (so you never wipe access by accident) —
use `iap-authz-revoke` to remove access intentionally.

## Enable IAP for a new app

1. **BackendConfig**: `spec.iap.enabled: true` (no `oauthclientCredentials`), and
   ensure the Service references it (`cloud.google.com/backend-config`).
2. **env.mk**: set `IAP_MEMBERS` to the authorized groups.
3. **makefile.mk**: `include ${OTICA_DIR}/makefiles/gke-iap-authz.mk`.
4. Deploy (`make kc-apply`), wait for the Ingress to sync, then `make iap-authz`.
5. (App side) Read the `X-Goog-Authenticated-User-Email` header to identify the
   user; health endpoints hit pod-to-pod must stay open (they bypass IAP/nginx).

`IAP_INGRESS` defaults to `${APP}-ing` and `IAP_ROLE` to
`roles/iap.httpsResourceAccessor`; override in `env.mk` only if your ingress is
named differently.

## Gotchas & hard constraints

### Custom → Google-managed client requires recreating the backend service
GKE refuses to switch an existing IAP backend from a custom OAuth client to the
managed one:
`Cannot switch to default OAuth once IAP credentials have been set`.
Dropping `oauthclientCredentials` (even with a disable→enable toggle) just loops
on this error. The only fix is to **recreate the backend service** — i.e. delete
and recreate the Ingress so a fresh backend service (no credential history) is
created, then enable IAP with no credentials.

### Recreating the Ingress changes the external IP — reserve it first
A GKE auto-assigned Ingress IP (annotation `static-ip: k8s2-fr-…`) is **released
to Google's pool when the Ingress is deleted and cannot be re-reserved**. Before
recreating, reserve a **user-managed** static IP and pin it:

```bash
gcloud compute addresses create <app>-<env>-ip --global --project <project>
```
```makefile
# <env>/env.mk
INGRESS_STATIC_IP_NAME=<app>-<env>-ip
```
Add `kubernetes.io/ingress.global-static-ip-name` to the ingress template when
this var is set. The managed certificate also re-provisions on the new LB
(expect a few minutes of HTTPS errors); in prod, do this in a maintenance window.

### IAP IAM is additive
You cannot subtract access at a lower level. Keep authorization **only** at the
backend-service level (`make iap-authz`); do not also keep a project-wide
`google_iap_web_iam_binding`, or it will widen access for every app.

## Verify end-to-end

```bash
# 1. IAP enabled + managed client (oauth2ClientId empty == managed)
gcloud compute backend-services list --project <project> \
  --filter="iap.enabled=true" --format="value(name, iap.oauth2ClientId)"

# 2. SSO challenge — expect a 302 to accounts.google.com
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://<app-fqdn>/

# 3. Authorized members on the backend service
make iap-authz-list

# 4. Pods stay Ready — health checks bypass IAP
```

## Terraform footprint

The project `terraform/iap.tf` should keep only the IAP API enablement and the
IAP→VM firewall (for SSH tunneling). Under the managed-client + per-app
`iap-authz` model it holds **no** OAuth client, Vault credentials, or web-wide
IAM binding.
+79 −0
Original line number Diff line number Diff line
ifndef GKE_IAP_AUTHZ_MK_INCLUDED
	GKE_IAP_AUTHZ_MK_INCLUDED := 1
unexport GKE_IAP_AUTHZ_MK_INCLUDED

# gke-iap-authz.mk: per-app IAP authorization for GKE Ingress backends.
#
# Manages roles/iap.httpsResourceAccessor on an app's GKE-generated backend
# service(s). The backend service name is dynamic/per-cluster, so it is resolved
# from the live Ingress annotation (ingress.kubernetes.io/backends); kc-config
# pins the right cluster, which auto-disambiguates environments.
#
# `iap-authz` is AUTHORITATIVE: it reconciles the live policy so the role's
# members are exactly IAP_MEMBERS (adds missing, removes dropped) via a
# read-modify-write that preserves the policy etag and any other bindings.
# IAP_MEMBERS in env.mk is the single source of truth. `iap-authz-revoke`
# removes specific members for one-off use.
#
# Use this instead of a project-wide google_iap_web_iam_binding when different
# apps need different access lists (IAM cannot be narrowed per app once granted
# at the web level).
#
# Requires: APP_NAMESPACE, GCP_PROJECT_ID, IAP_MEMBERS, and the kc-config target
# (provided by kube.mk). Set IAP_MEMBERS (space-separated IAM members) per app
# in env.mk, e.g.  IAP_MEMBERS=group:admins@example.edu group:devs@example.edu

ifndef GCP_PROJECT_ID
	missing_vars := ${missing_vars} GCP_PROJECT_ID
endif

ifndef APP_NAMESPACE
	missing_vars := ${missing_vars} APP_NAMESPACE
endif

# IAP access role and the ingress to read backend service names from. Defaults
# follow OTICA conventions; override per project if needed.
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

.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

.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

.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

# End of gke-iap-authz.mk
endif # GKE_IAP_AUTHZ_MK_INCLUDED