Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .github/workflows/platform-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,15 @@ jobs:
working-directory: ${{ env.TF_ROOT }}
run: terraform apply -auto-approve -lock-timeout=5m tfplan

# Re-sync app Cognito clients with the Lambda/config just applied, so
# parameters it adds (cognito-client-identity-provider, auth-status)
# exist before an app redeploys. Async; unchanged values are not
# rewritten, so nothing restarts. Non-blocking: the 03:30 reconcile
# catches up anyway.
- name: Reconcile app Cognito clients
continue-on-error: true
run: sh scripts/reconcile-app-auth.sh

# --------------------------------------------------------------------------
# Drift Detection — scheduled weekly, plan-only
# --------------------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/tf-apply.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,13 @@ jobs:
- name: Terraform Apply
working-directory: ${{ inputs.tf_root }}
run: terraform apply -auto-approve tfplan

# Non-blocking: checks Cognito sign-in right after apply (sync errors,
# redirect_mismatch, logout URL mismatch, unknown auth.groups). Waits
# for app-auth-sync to process the new request. Skips apps without auth.
- name: Auth doctor
if: hashFiles('app.yaml') != ''
continue-on-error: true
env:
APP_SERVICE: ${{ github.event.repository.name }}
run: python3 .platform/scripts/auth-doctor.py --app-yaml app.yaml --wait 180
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ terraform/state/
| `scripts/invoke-apply-gate.sh` | CI: invoke gate Lambda for apply credentials |
| `scripts/run-plan.sh` | CI: terraform plan with exit code handling |
| `scripts/upload-plan.sh` | CI: upload plan artifact to S3 |
| `scripts/auth-doctor.py` | CI (after app apply) / local: checks an app's Cognito sign-in end to end from auth-status + HTTP probes (tests: `scripts/tests/`) |
| `scripts/reconcile-app-auth.sh` | CI (after platform apply): queue an app-auth-sync reconcile |

## Naming

Expand Down Expand Up @@ -254,7 +256,9 @@ All parameters are in `eu-central-1`. Use `--profile javabin --region eu-central
| `/javabin/platform/atlassian-api-key` | SecureString | team-provisioner (Atlassian org admin API key, unscoped — full org admin) |
| `/javabin/platform-overrides/{repo}/{sha}` | SecureString | Risk gate override tokens (single-use) |
| `/javabin/apps/{team}/{app}/auth` | String | app.yaml `auth:` request (written by app TF, read by app-auth-sync) |
| `/javabin/apps/{team}/{app}/cognito-client-{id,secret}` | String / SecureString | Written by app-auth-sync, read by ECS execution role |
| `/javabin/apps/{team}/{app}/cognito-client-{id,secret,identity-provider}` | String / SecureString / String | Written by app-auth-sync (only on change; a change restarts the app), read by ECS execution role |
| `/javabin/apps/{team}/{app}/auth-status` | String (JSON) | Written by app-auth-sync, read by the team and `scripts/auth-doctor.py` |
| `/javabin/platform/google-oauth/internal/{client-id,client-secret}` | String / SecureString | Internal pool's Google OIDC IdP (identity TF; IdP is created only when both exist) |

## GCP Connection

Expand Down Expand Up @@ -296,7 +300,7 @@ The SA JSON key is at `/javabin/platform/google-admin-sa`, the impersonation tar

### Known Issues
- **ECS deploy stabilization**: platform-test-app task registers but service fails health check
- **Cognito**: pools deployed; internal pool's `GoogleSAML` IdP was created outside Terraform
- **Cognito**: pools deployed; internal pool's `GoogleSAML` IdP was created outside Terraform. It is replaced by the Terraform-managed Google OIDC IdP at cutover (docs/cognito-google-setup.md), then deleted
- **SSO sessions carry no `team` tag**: the Identity Center ABAC attribute (`${path:enterprise.team}`) is never set on users, so `${aws:PrincipalTag/team}` is empty for humans. Per-team permission sets (`javabin-team-{team}`) therefore use `javabin-team-{team}-guardrails` (the developer boundary rendered with the team name) instead. The ABAC-based `javabin-developer` permission set in `terraform/org` cannot reach team resources
- **App databases share one network path**: service-rds allows ingress from the shared ECS tasks SG, so every team's tasks can reach every team's DB port (password still required). Fix: per-app security groups
- **Team provisioner Lambda**: All sync functions working (Google/GitHub/Budget/Cognito/Identity Center). Password-set flow deployed.
Expand Down
40 changes: 35 additions & 5 deletions docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,13 @@ Full form:
auth:
pool: internal # 'internal' (default) or 'external'
client: confidential # confidential (default) | public | none — see below
callback_urls: # optional; https, or http://localhost for local dev
callback_urls: # optional; https on routing.host / extra_hosts, or http://localhost
- https://app.javazone.no/auth/callback
- http://localhost:3000/auth/callback
logout_urls: # optional; same rules
- https://app.javazone.no/
extra_hosts: # optional — other hosts the app serves callbacks on
- www.app.javazone.no
scopes: # optional, default ['openid', 'email', 'profile']
- openid
- email
Expand All @@ -214,6 +216,7 @@ auth:
env: # optional — also inject values under your own names
COGNITO_CLIENT_ID: NUXT_PUBLIC_OIDC_CLIENT_ID
COGNITO_ISSUER_URL: NUXT_PUBLIC_OIDC_AUTHORITY
COGNITO_IDENTITY_PROVIDER: NUXT_PUBLIC_OIDC_IDENTITY_PROVIDER
```

#### `scopes`
Expand All @@ -226,17 +229,17 @@ Any of `openid`, `email`, `profile`, `phone`, `aws.cognito.signin.user.admin`. T
|---|---|---|
| `confidential` | Server-side login (the server does the code exchange) | Client ID **and** secret |
| `public` | Browser apps doing OIDC with PKCE (oidc-client-ts, etc.) — a secret can't be kept in a browser | Client ID, no secret |
| `none` | APIs that only *verify* tokens issued to another app's client | Pool/issuer values only; no client, no callbacks, no `routing.host` needed |
| `none` | APIs that only *verify* tokens issued to another app's client | Pool/issuer values only; no client, no callbacks, no `routing.host` needed. `callback_urls`, `logout_urls`, `scopes` and `extra_hosts` are rejected (they would be ignored) |

A typical split frontend/backend: the frontend uses `client: public`, the API uses `client: none` and validates tokens against `COGNITO_ISSUER_URL` / `COGNITO_WELL_KNOWN_URL`.

A `client: none` API has no client ID of its own to check, so it accepts tokens issued to *any* client in the pool. Check `cognito:groups` (or the token's `client_id`) for anything that matters.

Changing `client` between `confidential` and `public` replaces the Cognito client, so its ID changes. Running tasks keep the old ID until they restart: redeploy after the change.
Changing `client` between `confidential` and `public` replaces the Cognito client, so its ID changes. The platform restarts the service when the new ID is written (app-secret-rollout), so running tasks don't keep the deleted ID. SPAs that bake the client ID in at *build* time (Vite `VITE_*`, Next `NEXT_PUBLIC_*` at build) must rebuild; runtime config (Nuxt `NUXT_PUBLIC_*`) picks it up on restart.

Both forms are supported and produce the same wiring.

- `internal` — Javabin heroes. The hosted UI sends users straight to Google Workspace (`GoogleSAML`), so apps don't need an `identity_provider` parameter. There is no password form or sign-up. Only `@java.no` accounts get a token; anyone else is refused at sign-in.
- `internal` — Javabin heroes. Every internal client signs in through the pool's IdP list (`internal_identity_providers` in `terraform/platform/identity/`): today Google Workspace SAML (`GoogleSAML`), after the Google cutover only `Google`. With a single IdP the hosted UI sends users straight to it, so apps don't need an `identity_provider` parameter; apps that pass one anyway should read `COGNITO_IDENTITY_PROVIDER` instead of hardcoding `GoogleSAML`. There is no password form or sign-up. Only `@java.no` accounts get a token; anyone else is refused at sign-in.
- `external` — public users.
- `none` or omitted — no Cognito integration.

Expand All @@ -247,16 +250,42 @@ Both forms are supported and produce the same wiring.
If `callback_urls` is omitted, defaults to `[https://{routing.host}/, https://{routing.host}/auth/callback]`.
If `logout_urls` is omitted, defaults to `[https://{routing.host}/]`.

An explicit list *replaces* the defaults. Apps that redirect elsewhere (Spring: `/login/oauth2/code/cognito`, NextAuth: `/api/auth/callback/cognito`) must list that path.

#### URL rules (checked when terraform/ is generated)

- `https://`, except `http://localhost` / `http://127.0.0.1` for local development.
- The host must be `routing.host` or listed in `auth.extra_hosts` (localhost excepted), so a typo fails in the PR instead of as `redirect_mismatch` in the browser.
- No `#fragment` (also rejected by `app-auth-sync`).

The host rule runs when `app.yaml` is expanded (`generate-modules.py`); the platform Lambda checks only the scheme and fragments, so it is a guard against typos, not a security boundary.

#### Logout

Cognito's `/logout?client_id=…&logout_uri=…` redirects back only when `logout_uri` matches a `logout_urls` entry **exactly** (trailing slash and path included). Anything else lands on the hosted UI's `/login` page. Cognito ignores `post_logout_redirect_uri`; send `logout_uri`.

#### Sign-in denied

A user in none of `auth.groups` gets no token: Cognito redirects to your callback with `error=access_denied&error_description=PreTokenGeneration failed with error You are not in a group that has access to this app.` Render `error_description` on your callback page instead of looping back to sign-in.

#### How the client is created

App CI cannot create Cognito clients: the pools belong to the platform, and a grant on a pool would let any team rewrite every other team's client. So the app only *requests* one:

1. The generated Terraform writes the client config to SSM `/javabin/apps/{team}/{name}/auth`. The permission boundary lets a team write only under its own prefix, so the path proves ownership.
2. The platform Lambda `javabin-app-auth-sync` reacts to the change (EventBridge), creates or updates the client `{team}-{name}` in the requested pool, and writes `/javabin/apps/{team}/{name}/cognito-client-id` and `cognito-client-secret`.
2. The platform Lambda `javabin-app-auth-sync` reacts to the change (EventBridge), creates or updates the client `{team}-{name}` in the requested pool, and writes `/javabin/apps/{team}/{name}/cognito-client-id`, `cognito-client-secret` and `cognito-client-identity-provider` (only when a value changes; a change restarts the service). It reports the outcome in `/javabin/apps/{team}/{name}/auth-status` (JSON, readable by the team): `status` (`synced` / `invalid` / `error` + `error`), `client_id`, `identity_providers`, `drift_corrected` (client settings someone changed outside the platform, reverted by the daily reconcile), `unknown_groups` (`auth.groups` entries that don't exist in the registry — nobody can sign in through them), `client_id_changed_at`, `synced_at`. Your team's SSO role cannot write it, but your CI role can (as it can the `cognito-client-*` parameters); the next sync overwrites any such change, so treat it as a report.
3. Deleting `auth:` deletes the request parameter, and the Lambda deletes the client and its credentials. A daily reconcile repairs anything an event missed.

On the very first deploy with `auth:`, the first task can start a few seconds before the credentials exist; ECS retries it.

#### auth doctor

`scripts/auth-doctor.py` (platform repo) checks the whole chain with the team's own credentials: the request matches app.yaml, `auth-status` is `synced` with the same client ID, `/oauth2/authorize` redirects to the IdP for every callback URL (no `redirect_mismatch`), `/logout` returns to every logout URL, and (warning only) the service's current deployment started after the client ID last changed. App CI runs it after `terraform apply` (non-blocking; failures show as annotations). Locally, from the app repo:

```bash
python3 ../platform/scripts/auth-doctor.py --app-yaml app.yaml # add --json for scripts
```

#### Injected environment variables

| Var | Source | Notes |
Expand All @@ -267,6 +296,7 @@ On the very first deploy with `auth:`, the first task can start a few seconds be
| `COGNITO_WELL_KNOWN_URL` | `{issuer}/.well-known/openid-configuration` | OIDC discovery document |
| `COGNITO_CLIENT_ID` | SSM (ECS task `secrets`) | `confidential` and `public` only |
| `COGNITO_CLIENT_SECRET` | SSM SecureString (ECS task `secrets`) | `confidential` only |
| `COGNITO_IDENTITY_PROVIDER` | SSM (ECS task `secrets`) | `confidential` and `public` only. The IdP the client signs in through (`GoogleSAML`, `Google`, or `COGNITO`), for apps that send `identity_provider` themselves. Changes at the IdP cutover, which restarts the service |
| `COGNITO_GROUPS` | Comma-joined list from `auth.groups` | Only when `groups` is set |

`auth.env` adds the same values under extra names (both names are injected). Only names injected for your `client` type can be aliased.
Expand Down
Loading
Loading