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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ terraform/state/
|--------|------|
| `terraform/modules/platform-data/` | Read-only data sources for shared infra |
| `terraform/modules/ecr-repo/` | ECR repository with lifecycle policy |
| `terraform/modules/service-routing/` | ALB target group + listener rule + DNS |
| `terraform/modules/service-routing/` | ALB target group + listener rule + DNS; optional per-path ALB sign-in (`routing.login`) |
| `terraform/modules/service-role/` | ECS task IAM role with composable policies |
| `terraform/modules/ecs-service/` | ECS Fargate service |
| `terraform/modules/service-bucket/` | S3 bucket with IAM policy output |
Expand Down Expand Up @@ -169,8 +169,8 @@ terraform/state/
| `budget-enforcer` | Scales ECS services to zero when team exceeds 200% budget |
| `resource-tagger` | EventBridge-triggered, auto-tags created-by + commit on new resources |
| `ci-broker` | Verifies the job's GitHub OIDC token (repo from signed claims), checks repo ∈ app.yaml team, vends short-lived team role credentials |
| `app-auth-sync` | Creates/deletes Cognito app clients requested by app.yaml `auth:` (SSM request → client + credentials + allowed groups) |
| `auth-claims` | Internal pool pre-token trigger: registry groups → `cognito:groups`, enforces app `auth.groups` (in `identity/`) |
| `app-auth-sync` | Creates/deletes Cognito app clients requested by app.yaml `auth:` and `routing.login` (SSM request → client + credentials + allowed groups) |
| `auth-claims` | Pre-token trigger: registry groups → `cognito:groups` (internal pool only), enforces per-client allowed groups; runs as `auth-claims-external` on the external pool (in `identity/`) |
| `plan-review` | LLM review of app Terraform plans with platform credentials; writes `risk.json` (invoked by app tf-plan) |
| `app-secret-rollout` | Restarts `{team}-{app}` when one of its secrets in `/javabin/apps/` changes (ECS reads secrets only at task start) |

Expand Down
96 changes: 94 additions & 2 deletions docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,97 @@ routing:
- `host` creates a Route53 A record (in the zone matching the host's domain) pointing to the platform ALB and an ALB listener rule matching the Host header.
- `priority` determines listener rule evaluation order. Must be unique across all apps. Coordinate via the registry.

#### routing.login — sign-in at the load balancer

`login` makes the platform load balancer require sign-in before a request reaches the app. You pick, per path, which Cognito pool users sign in to and which groups may pass. The app needs no auth code, so this works for simple apps and static web pages (see [recipe](#static-pages-behind-sign-in)). It is independent of [`auth`](#auth) (where the app does OIDC itself); an app can use both.

```yaml
routing:
host: wiki.java.no
priority: 120
login:
- paths: ["/admin/*"] # rules are checked in this order; first match wins
groups: [styret]
- paths: ["/assets/*", "/health"]
public: true # no sign-in for these
- paths: ["/*"] # default when paths is left out
groups: [helter, team-core]
pool: internal # default
```

Shorthands:

```yaml
routing:
login: internal # whole site, any user of the internal pool
# or
routing:
login: # one rule; paths defaults to ["/*"]
groups: [helter]
```

| Field | Default | Meaning |
|---|---|---|
| `paths` | `["/*"]` | 1-4 ALB path patterns. Case-sensitive; `*` and `?` are wildcards; max 128 chars. On protected rules, `/x/*` also protects the bare `/x` (added for you unless you list `/x` yourself), and counts toward the 4. |
| `pool` | `internal` | `internal`: Javabin heroes, signed in through Google Workspace (SAML) only. The pool has no self sign-up and its clients offer no username/password sign-in. `external`: attendees, partners and other public users. Today that means Cognito email/password sign-up; other identity providers (e.g. Google, Vipps) are not set up on that pool yet. |
| `groups` | `[]` (any pool user) | Only users in at least one of these groups get through. Internal pool: registry groups (`groups/groups.yaml` with `cognito: true`, and `team-<team>`). External pool: native Cognito groups of the external pool. Nothing in the platform creates or fills those groups yet; until it does, an admin creates them by hand (`aws cognito-idp create-group` / `admin-add-user-to-group` on `javabin-external`), so ask the platform team. |
| `public` | `false` | Forward without sign-in. Cannot be combined with `pool` or `groups`. |

What happens:

- A user without a session is redirected to the pool's sign-in page, then back to the page they asked for. A user outside `groups` is refused by Cognito at sign-in ("You are not in a group that has access to this app.") and never gets a session, so the app is never reached. Where the message shows (Cognito's sign-in page or the load balancer's error page) depends on the sign-in flow.
- Every path not matched by a rule keeps working as before, without sign-in.
- Sessions last 8 hours. A registry change (e.g. removing someone from a group) takes effect at their next sign-in, so at most 8 hours later.
- An app.yaml change to a rule's `pool` or `groups`, or a reorder that gives a rule a new position, renames that rule's session cookie, so everyone signs in again and the new groups apply at once. Changing only `paths` keeps existing sessions.
- One sign-in covers all rules of the app: Cognito remembers the user, so a second rule only redirects.

Rules and priorities:

- Each rule becomes its own ALB listener rule at the priorities **directly below** `routing.priority`: the first rule gets `priority - 1`, the next `priority - 2`, and so on. Leave that room free: if another app already uses one of those priorities, apply fails with `PriorityInUse`. `priority` must be larger than the number of rules. Nothing checks this across apps yet, so pick a `priority` with at least 10 free numbers below it (e.g. a multiple of 20 when registering a new app); apps whose priority is right next to another app's (e.g. 100 and 101) cannot add rules without moving.
- At most 10 rules. A path may appear only once, and a rule after a `/*` rule is an error (it could never match).
- Protect `/*` and open the exceptions with `public: true`, rather than protecting a few subpaths. The load balancer matches the raw request path: case-sensitively, without decoding `%xx` and without resolving `..`. An app or framework that does any of these can be reached past a subpath rule, e.g. `/Admin`, `/%61dmin/` or `/x/../admin/` past `/admin/*`. The generator warns when no rule covers `/*`.

What the app receives on signed-in requests (headers added by the load balancer):

| Header | Content |
|---|---|
| `x-amzn-oidc-identity` | The user's `sub` |
| `x-amzn-oidc-data` | JWT with the user's claims (`email`, `name`, ...), signed by the load balancer (ES256). Verify it with the key at `https://public-keys.auth.elb.eu-central-1.amazonaws.com/<kid>` and check that `signer` is the platform ALB's ARN. |
| `x-amzn-oidc-accesstoken` | The Cognito access token, with the user's groups in `cognito:groups`. Verify it against the pool's issuer (`COGNITO_ISSUER_URL` if the app also uses `auth`). |

Caveats:

- On paths that are not protected, a client can send these headers itself. Verify the signature before trusting them anywhere.
- The load balancer has no logout endpoint. To sign out, expire the `javabin-auth-p<N>-*` cookies and redirect to the pool's `/logout`.
- How it works: each protected rule has its own Cognito client, `<team>-<name>.login-<N>`, requested through SSM (`/javabin/apps/<team>/<name>/login-<N>/auth`) and created by the platform's `javabin-app-auth-sync` Lambda, exactly like [`auth`](#how-the-client-is-created). The first apply waits 60 seconds for the client. If it fails with `ParameterNotFound`, the client was not created in time: re-run the pipeline.

##### Static pages behind sign-in

There is no separate static-site hosting: serve the files from a small container and let `login` protect it. `login` protects apps that run on the platform; it cannot proxy to a site hosted elsewhere (the load balancer only forwards to its own target groups).

```dockerfile
FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY site/ /usr/share/nginx/html/
```

```yaml
name: styret-docs
team: core
compute:
cpu: 256
memory: 512
port: 8080
health_check: /
readonly_root_filesystem: false # nginx writes its cache and pid outside /tmp
routing:
host: styret.javabin.no
priority: 130
login:
groups: [styret]
```

The health check comes from inside the VPC and is not affected by `login`.

### resources

Optional infrastructure resources. Each creates the AWS resource and auto-wires environment variables and IAM policies to the ECS task.
Expand Down Expand Up @@ -233,7 +324,7 @@ Changing `client` between `confidential` and `public` replaces the Cognito clien
Both forms are supported and produce the same wiring.

- `internal` — Javabin heroes. Sign-in offers every identity provider on the pool (Google Workspace SAML) plus Cognito users.
- `external` — public users.
- `external` — public users. Tokens carry only native Cognito groups of the external pool; registry groups are never added there, since anyone can sign up with any email.
- `none` or omitted — no Cognito integration.

`auth:` with a client needs `routing.host` or explicit `callback_urls` — the defaults are derived from the host.
Expand All @@ -258,7 +349,7 @@ On the very first deploy with `auth:`, the first task can start a few seconds be
| Var | Source | Notes |
|---|---|---|
| `COGNITO_USER_POOL_ID` | Pool looked up by name | Used to construct the issuer URL on the backend |
| `COGNITO_DOMAIN` | Hosted-UI FQDN of the pool | e.g. `javabin-internal.auth.eu-central-1.amazoncognito.com`; empty for external when no custom domain is configured |
| `COGNITO_DOMAIN` | Hosted-UI FQDN of the pool | e.g. `javabin-internal.auth.eu-central-1.amazoncognito.com` (external: `javabin-external.auth.eu-central-1.amazoncognito.com`) |
| `COGNITO_ISSUER_URL` | `https://cognito-idp.{region}.amazonaws.com/{pool_id}` | OIDC issuer for JWT validation |
| `COGNITO_WELL_KNOWN_URL` | `{issuer}/.well-known/openid-configuration` | OIDC discovery document |
| `COGNITO_CLIENT_ID` | SSM (ECS task `secrets`) | `confidential` and `public` only |
Expand Down Expand Up @@ -352,3 +443,4 @@ All app resources are prefixed with the team name for ABAC enforcement.
| CloudWatch logs | `/ecs/{team}/{name}` |
| DNS record | `{routing.host}` |
| SSM (Cognito) | `/javabin/apps/{team}/{name}/cognito-client-{id,secret}` |
| Cognito client (`routing.login`) | `{team}-{name}.login-{N}`, credentials under `/javabin/apps/{team}/{name}/login-{N}/` |
8 changes: 6 additions & 2 deletions docs/lambda-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig
**Trigger:** EventBridge "Parameter Store Change" for `/javabin/apps/*/*/auth` (Create/Update/Delete), plus a daily `{"action": "reconcile"}` at 03:30 UTC
**Purpose:** Creates, updates and deletes the Cognito app clients requested by `app.yaml` `auth:`. The request parameter's path identifies the team (only that team can write there), so no caller-supplied identity is trusted.

**Flow:** Re-read the request → validate (pool, client_type, https/localhost URLs, scopes, allowed_groups) → upsert client `{team}-{app}` in the requested pool with every pool IdP + `COGNITO` (a client_type change replaces the client, since the secret setting is immutable) → remove it from the other pool → write `/javabin/apps/{team}/{app}/cognito-client-id` (String) and, for confidential clients, `cognito-client-secret` (SecureString), tagged `team={team}` → record `allowed_groups` in `javabin-auth-memberships`. A missing request deletes the client and both parameters. Reconcile also removes clients whose credential parameter exists but whose request is gone; clients created by other means are never touched.
**Flow:** Re-read the request → validate (pool, client_type, https/localhost URLs, scopes, allowed_groups) → upsert client `{team}-{app}` in the requested pool with every pool IdP, plus `COGNITO` (username/password) on the external pool only (a client_type change replaces the client, since the secret setting is immutable) → remove it from the other pool → write `/javabin/apps/{team}/{app}/cognito-client-id` (String) and, for confidential clients, `cognito-client-secret` (SecureString), tagged `team={team}` → record `allowed_groups` in `javabin-auth-memberships`. A missing request deletes the client and both parameters. Reconcile also removes clients whose credential parameter exists but whose request is gone; clients created by other means are never touched.

**Slots:** a request one level down, `/javabin/apps/{team}/{app}/login-N/auth` (written by service-routing for each `routing.login` rule), gets its own client `{team}-{app}.login-N` with credentials under `/javabin/apps/{team}/{app}/login-N/`. `.` cannot occur in app names, so slot clients never collide with another app's client. Other subpaths are ignored.

**Concurrency:** reserved concurrency 1, so two changes to one app cannot race into duplicate clients.

Expand All @@ -136,7 +138,9 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig
**Trigger:** Cognito pre-token-generation (V2) on the internal pool (`javabin-internal`), every sign-in and token refresh
**Purpose:** Puts the user's registry groups into `cognito:groups` (ID and access token), and refuses sign-in when the app client has `allowed_groups` and the user is in none of them.

**Data:** `javabin-auth-memberships` (DynamoDB). `group#<name>` items (members' emails) are written by team-provisioner on registry merges; `client#<client_id>` items (`allowed_groups`) by app-auth-sync. Memberships are cached for 60 s per container.
**Data:** `javabin-auth-memberships` (DynamoDB). `group#<name>` items (members' emails) are written by team-provisioner on registry merges; `client#<client_id>` items (`allowed_groups`) by app-auth-sync. Memberships are cached for 60 s per container. Registry groups are granted only for an email the pool vouches for: federated users (`cognito:user_status = EXTERNAL_PROVIDER`) or `email_verified = true`.

**External pool:** the same code runs as `javabin-auth-claims-external` with `REGISTRY_GROUPS=false`. Anyone can sign up in the external pool with any email, so registry groups (keyed by email) are never added there; native Cognito groups still go into `cognito:groups`, and per-client `allowed_groups` are still enforced.

**Lives in:** `terraform/platform/identity/` (the pool references it, so it can't sit in `lambdas/` without a module cycle).

Expand Down
9 changes: 6 additions & 3 deletions docs/reusable-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ ECR repository with 4-rule lifecycle policy.

ALB target group + listener rule (host-header routing) + Route53 A record (alias to ALB).

**Required inputs:** `name`, `project`, `vpc_id`, `https_listener_arn`, `listener_rule_priority`, `host_header`, `route53_zone_id`, `alb_dns_name`, `alb_zone_id`
**Required inputs:** `name`, `team`, `vpc_id`, `https_listener_arn`, `listener_rule_priority`, `host_header`, `route53_zone_id`, `alb_dns_name`, `alb_zone_id`
**Optional inputs:** `project`, `login_rules` (app.yaml `routing.login`), `login_session_timeout` (default 8 h), `login_client_wait` (default `60s`), `internal_identity_provider` (default `GoogleSAML`)
**Outputs:** `target_group_arn`, `target_group_arn_suffix`, `dns_name`

`login_rules` (map keyed `p1`, `p2`, ...) adds one listener rule per entry, matching host + paths at the entry's priority (below `listener_rule_priority`). Protected entries get an `authenticate-oidc` action against the chosen Cognito pool, with a per-rule client `{team}-{name}.login-N` requested through a nested `cognito-app-client` (slot `login-N`); group restrictions are enforced by Cognito (auth-claims). `public` entries forward without sign-in. The host rule `depends_on` them, so a new app serves nothing until they exist. `authenticate-cognito` is not used: it needs `cognito-idp:DescribeUserPoolClient` on the platform pools, which the developer boundary denies. Requires the `hashicorp/time` provider (for the wait on the async client creation).

## service-role

ECS task IAM role with CloudWatch Logs permissions + composable additional policies.
Expand Down Expand Up @@ -150,8 +153,8 @@ Auto-wires: resource env vars (bucket names, table names, queue URLs), secret AR

Requests a Cognito OAuth client for the app. It does **not** create the client: it writes the request to SSM `/{project}/apps/{team}/{app_name}/auth`, and the platform `app-auth-sync` Lambda creates the client `{team}-{app_name}` and writes its credentials next to the request (`cognito-client-id`, `cognito-client-secret`). See [app-yaml-reference.md#auth](app-yaml-reference.md#auth) for why.

**Inputs:** `app_name`, `team`, `pool` (internal/external), `client_type` (confidential/public/none), `callback_urls`, `logout_urls`, `allowed_oauth_scopes`, `allowed_groups`, `external_pool_custom_domain`
**Outputs:** `user_pool_id`, `user_pool_domain`, `issuer_url`, `well_known_url`, `client_id_arn`, `client_secret_arn` (SSM ARNs for ECS `secrets`), `request_parameter_name`
**Inputs:** `app_name`, `team`, `pool` (internal/external), `client_type` (confidential/public/none), `callback_urls`, `logout_urls`, `allowed_oauth_scopes`, `allowed_groups`, `external_pool_custom_domain`, `slot` (optional `login-N`: an extra client `{team}-{app_name}.login-N` with everything under `/{project}/apps/{team}/{app_name}/login-N/`)
**Outputs:** `user_pool_id`, `user_pool_domain`, `issuer_url`, `well_known_url`, `client_id_arn`, `client_secret_arn` (SSM ARNs for ECS `secrets`), `client_id_parameter_name`, `client_secret_parameter_name`, `request_parameter_name`, `request_value` (the request JSON; depends on the parameter existing)

## Policy Composition Pattern

Expand Down
Loading
Loading