Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ local release = base.pipeline(
enabled: true,
staticIpName: 'optio',
cloudArmorPolicy: 'optio',
iap: { enabled: true },
managedCertificate: {
enabled: true,
domains: ['optio.gynzy.dev'],
Expand All @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/Release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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() }}"
Expand Down
101 changes: 101 additions & 0 deletions docs/superpowers/specs/2026-07-10-iap-ingress-design.md
Original file line number Diff line number Diff line change
@@ -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/<CLIENT_ID>: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.
12 changes: 12 additions & 0 deletions helm/optio/templates/backend-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
16 changes: 16 additions & 0 deletions helm/optio/templates/iap-secret.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
6 changes: 6 additions & 0 deletions helm/optio/values.production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions helm/optio/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<CLIENT_ID>:handleRedirect
# 2. Grant access:
# gcloud iap web add-iam-policy-binding \
# --member=domain:<your-domain> --role=roles/iap.httpsResourceAccessor
iap:
enabled: false
managedCertificate:
enabled: false
domains: []
Expand Down
Loading