diff --git a/.github.jsonnet b/.github.jsonnet index 65abd79c..991af857 100644 --- a/.github.jsonnet +++ b/.github.jsonnet @@ -179,6 +179,7 @@ local release = base.pipeline( enabled: true, staticIpName: 'optio', cloudArmorPolicy: 'optio', + iap: { enabled: true }, managedCertificate: { enabled: true, domains: ['optio.gynzy.dev'], @@ -195,6 +196,7 @@ local release = base.pipeline( }, publicUrl: 'https://optio.gynzy.dev', auth: { + disabled: true, google: { clientId: misc.secret('GOOGLE_OAUTH_CLIENT_ID'), clientSecret: misc.secret('GOOGLE_OAUTH_CLIENT_SECRET'), diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 0f943a67..5de4ff9a 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -100,7 +100,7 @@ "namespace": "optio" "release": "optio" "token": "${{ github.token }}" - "values": "{\"agent\":{\"image\":{\"pullPolicy\":\"IfNotPresent\",\"repository\":\"europe-docker.pkg.dev/unicorn-985/private-images/optio-agent-base\",\"tag\":\"deploy-${{ github.event.pull_request.head.sha || github.sha }}\"},\"imagePullPolicy\":\"IfNotPresent\"},\"api\":{\"image\":{\"repository\":\"europe-docker.pkg.dev/unicorn-985/private-images/optio-api\",\"tag\":\"deploy-${{ github.event.pull_request.head.sha || github.sha }}\"}},\"auth\":{\"google\":{\"clientId\":\"${{ secrets.GOOGLE_OAUTH_CLIENT_ID }}\",\"clientSecret\":\"${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}\"}},\"cloudSqlProxy\":{\"enabled\":true,\"instanceConnectionName\":\"gh-runners:europe-west4:optio\"},\"encryption\":{\"key\":\"${{ secrets.ENCRYPTION_KEY }}\"},\"externalDatabase\":{\"url\":\"${{ secrets.EXTERNAL_DATABASE_URL }}\"},\"ingress\":{\"enabled\":true,\"gke\":{\"cloudArmorPolicy\":\"optio\",\"enabled\":true,\"managedCertificate\":{\"domains\":[\"optio.gynzy.dev\"],\"enabled\":true},\"staticIpName\":\"optio\"},\"hosts\":[{\"host\":\"optio.gynzy.dev\",\"paths\":[{\"path\":\"/*\",\"pathType\":\"ImplementationSpecific\",\"service\":\"web\"},{\"path\":\"/api/*\",\"pathType\":\"ImplementationSpecific\",\"service\":\"api\"},{\"path\":\"/ws/*\",\"pathType\":\"ImplementationSpecific\",\"service\":\"api\"}]}]},\"optio\":{\"image\":{\"repository\":\"europe-docker.pkg.dev/unicorn-985/private-images/optio-optio\",\"tag\":\"deploy-${{ github.event.pull_request.head.sha || github.sha }}\"}},\"postgresql\":{\"enabled\":false},\"publicUrl\":\"https://optio.gynzy.dev\",\"web\":{\"image\":{\"repository\":\"europe-docker.pkg.dev/unicorn-985/private-images/optio-web\",\"tag\":\"deploy-${{ github.event.pull_request.head.sha || github.sha }}\"}}}" + "values": "{\"agent\":{\"image\":{\"pullPolicy\":\"IfNotPresent\",\"repository\":\"europe-docker.pkg.dev/unicorn-985/private-images/optio-agent-base\",\"tag\":\"deploy-${{ github.event.pull_request.head.sha || github.sha }}\"},\"imagePullPolicy\":\"IfNotPresent\"},\"api\":{\"image\":{\"repository\":\"europe-docker.pkg.dev/unicorn-985/private-images/optio-api\",\"tag\":\"deploy-${{ github.event.pull_request.head.sha || github.sha }}\"}},\"auth\":{\"disabled\":true,\"google\":{\"clientId\":\"${{ secrets.GOOGLE_OAUTH_CLIENT_ID }}\",\"clientSecret\":\"${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}\"}},\"cloudSqlProxy\":{\"enabled\":true,\"instanceConnectionName\":\"gh-runners:europe-west4:optio\"},\"encryption\":{\"key\":\"${{ secrets.ENCRYPTION_KEY }}\"},\"externalDatabase\":{\"url\":\"${{ secrets.EXTERNAL_DATABASE_URL }}\"},\"ingress\":{\"enabled\":true,\"gke\":{\"cloudArmorPolicy\":\"optio\",\"enabled\":true,\"iap\":{\"enabled\":true},\"managedCertificate\":{\"domains\":[\"optio.gynzy.dev\"],\"enabled\":true},\"staticIpName\":\"optio\"},\"hosts\":[{\"host\":\"optio.gynzy.dev\",\"paths\":[{\"path\":\"/*\",\"pathType\":\"ImplementationSpecific\",\"service\":\"web\"},{\"path\":\"/api/*\",\"pathType\":\"ImplementationSpecific\",\"service\":\"api\"},{\"path\":\"/ws/*\",\"pathType\":\"ImplementationSpecific\",\"service\":\"api\"}]}]},\"optio\":{\"image\":{\"repository\":\"europe-docker.pkg.dev/unicorn-985/private-images/optio-optio\",\"tag\":\"deploy-${{ github.event.pull_request.head.sha || github.sha }}\"}},\"postgresql\":{\"enabled\":false},\"publicUrl\":\"https://optio.gynzy.dev\",\"web\":{\"image\":{\"repository\":\"europe-docker.pkg.dev/unicorn-985/private-images/optio-web\",\"tag\":\"deploy-${{ github.event.pull_request.head.sha || github.sha }}\"}}}" "version": "${{ github.sha }}" "wait": "false" - "if": "${{ always() }}" diff --git a/docs/superpowers/specs/2026-07-10-iap-ingress-design.md b/docs/superpowers/specs/2026-07-10-iap-ingress-design.md new file mode 100644 index 00000000..c7545a90 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-iap-ingress-design.md @@ -0,0 +1,101 @@ +# Google IAP at the Ingress — Design + +Date: 2026-07-10 +Status: Approved + +## Goal + +Put a Google identity layer (IAP) in front of `optio.gynzy.dev` at the load +balancer, before any traffic reaches optio pods. Optio's own application login +stays as-is (it will be disabled later, separately). + +## Context + +Production runs the chart's GKE-native ingress (GCLB) with paths `/*` → web, +`/api/*` + `/ws/*` → api, a Cloud Armor policy, and a Google-managed +certificate. The chart already renders `BackendConfig` resources for both +services, and `auth.google.clientId` / `auth.google.clientSecret` values +already exist (they feed optio's own Google login via the `optio-config` +secret). + +## Decisions + +- **Mechanism**: Google Identity-Aware Proxy enabled per backend service via + the existing `BackendConfig` templates. No new proxy components. +- **Coverage**: IAP on **both** web and api backends. External webhook + callers (`/api/webhooks/*`, `/api/hooks/*`, Slack events) are knowingly + blocked — no bypass backend. +- **App auth**: unchanged. IAP is a pure extra gate. +- **OAuth client**: reuse the existing Google OAuth client from + `auth.google.*` values. IAP requires a secret with keys exactly + `client_id` / `client_secret`, so the chart renders a small dedicated + secret from those same values. + +## Chart changes + +1. `values.yaml` — new setting: + + ```yaml + ingress: + gke: + iap: + enabled: false + ``` + +2. New template `templates/iap-secret.yaml` — when + `ingress.enabled && ingress.gke.enabled && ingress.gke.iap.enabled`, + render: + + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: {{ .Release.Name }}-iap-oauth + stringData: + client_id: {{ .Values.auth.google.clientId }} + client_secret: {{ .Values.auth.google.clientSecret }} + ``` + + Fail the render (`fail`) if IAP is enabled but `auth.google.clientId` + is empty. + +3. `templates/backend-config.yaml` — when IAP is enabled, both + BackendConfigs gain: + + ```yaml + iap: + enabled: true + oauthclientCredentials: + secretName: {{ .Release.Name }}-iap-oauth + ``` + +4. `values.production.yaml` — document the `iap` block (left disabled), plus + the manual GCP steps. + +## Manual GCP steps (one-time, outside helm) + +1. Add redirect URI + `https://iap.googleapis.com/v1/oauth/clientIds/:handleRedirect` + to the existing Google OAuth client. +2. Grant `roles/iap.httpsResourceAccessor` to the gynzy.com domain (or a + Google group) on the IAP-protected backend services. + +## Behavior notes + +- GCLB health checks bypass IAP — `/api/health` checks keep working. +- WebSockets (`/ws/*`) work through IAP via the browser session cookie. +- Cloud Armor policy coexists with IAP on the same backend services. +- Agent pods talk to the API via in-cluster service DNS, bypassing the + ingress — unaffected. +- Rollback: `helm upgrade` with `ingress.gke.iap.enabled=false`. + +## Testing + +- `helm template` render checks (no unit-test framework in this chart): IAP + block present on both BackendConfigs when enabled, absent when disabled, + secret rendered with correct keys, render failure when google client id + missing. +- `helm lint` passes. +- Post-deploy verification: anonymous curl to `https://optio.gynzy.dev` + returns a Google sign-in redirect; authenticated gynzy browser session + reaches optio; task log streaming over WS still works. diff --git a/helm/optio/templates/backend-config.yaml b/helm/optio/templates/backend-config.yaml index 740d7019..533c339d 100644 --- a/helm/optio/templates/backend-config.yaml +++ b/helm/optio/templates/backend-config.yaml @@ -8,6 +8,12 @@ metadata: {{- include "optio.labels" . | nindent 4 }} spec: timeoutSec: 3600 + {{- if and .Values.ingress.gke.iap .Values.ingress.gke.iap.enabled }} + iap: + enabled: true + oauthclientCredentials: + secretName: {{ .Release.Name }}-iap-oauth + {{- end }} {{- if .Values.ingress.gke.cloudArmorPolicy }} securityPolicy: name: {{ .Values.ingress.gke.cloudArmorPolicy | quote }} @@ -25,6 +31,12 @@ metadata: labels: {{- include "optio.labels" . | nindent 4 }} spec: + {{- if and .Values.ingress.gke.iap .Values.ingress.gke.iap.enabled }} + iap: + enabled: true + oauthclientCredentials: + secretName: {{ .Release.Name }}-iap-oauth + {{- end }} {{- if .Values.ingress.gke.cloudArmorPolicy }} securityPolicy: name: {{ .Values.ingress.gke.cloudArmorPolicy | quote }} diff --git a/helm/optio/templates/iap-secret.yaml b/helm/optio/templates/iap-secret.yaml new file mode 100644 index 00000000..4f3e770c --- /dev/null +++ b/helm/optio/templates/iap-secret.yaml @@ -0,0 +1,16 @@ +{{- if and .Values.ingress.enabled .Values.ingress.gke .Values.ingress.gke.enabled .Values.ingress.gke.iap .Values.ingress.gke.iap.enabled }} +{{- if or (not .Values.auth.google.clientId) (not .Values.auth.google.clientSecret) }} +{{- fail "ingress.gke.iap.enabled requires auth.google.clientId and auth.google.clientSecret" }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-iap-oauth + namespace: {{ .Values.namespace }} + labels: + {{- include "optio.labels" . | nindent 4 }} +type: Opaque +stringData: + client_id: {{ .Values.auth.google.clientId | quote }} + client_secret: {{ .Values.auth.google.clientSecret | quote }} +{{- end }} diff --git a/helm/optio/values.production.yaml b/helm/optio/values.production.yaml index ef24b181..419c576c 100644 --- a/helm/optio/values.production.yaml +++ b/helm/optio/values.production.yaml @@ -97,6 +97,12 @@ ingress: enabled: true staticIpName: optio cloudArmorPolicy: optio + # Google IAP: gate the whole site behind Google sign-in at the load + # balancer. Requires auth.google.clientId/clientSecret. Blocks external + # webhook callers. See ingress.gke.iap comments in values.yaml for the + # one-time GCP setup (OAuth redirect URI + IAM grant). + iap: + enabled: false managedCertificate: enabled: true domains: diff --git a/helm/optio/values.yaml b/helm/optio/values.yaml index e2778051..788969f8 100644 --- a/helm/optio/values.yaml +++ b/helm/optio/values.yaml @@ -400,6 +400,18 @@ ingress: enabled: false staticIpName: "" cloudArmorPolicy: "" + # Google Identity-Aware Proxy. When enabled, every request through the + # GKE ingress must carry a Google identity before reaching optio pods. + # Reuses the OAuth client from auth.google.clientId/clientSecret. + # NOTE: blocks external webhook callers (/api/webhooks, /api/hooks, Slack). + # One-time GCP setup: + # 1. Add redirect URI to the OAuth client: + # https://iap.googleapis.com/v1/oauth/clientIds/:handleRedirect + # 2. Grant access: + # gcloud iap web add-iam-policy-binding \ + # --member=domain: --role=roles/iap.httpsResourceAccessor + iap: + enabled: false managedCertificate: enabled: false domains: []