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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ terraform/platform/
compute/ ECS cluster, ECR base config
monitoring/ SNS, EventBridge, Config, GuardDuty, Security Hub
lambdas/ slack-alert, cost-report, daily-cost-check, compliance-reporter, resource-tagger, budget-enforcer, override-cleanup, team-provisioner, apply-gate, securityhub-summary, password-set, ci-broker, app-auth-sync, plan-review
identity/ Cognito user pools (internal + external). Identity Center is in terraform/org/
identity/ Cognito user pools (internal + external), auth-claims trigger, auth memberships table. Identity Center is in terraform/org/
```

### Terraform — Org (human-applied, no CI)
Expand Down Expand Up @@ -169,7 +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) |
| `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/`) |
| `plan-review` | LLM review of app Terraform plans with platform credentials; writes `risk.json` (invoked by app tf-plan) |

### Scripts
Expand Down
49 changes: 37 additions & 12 deletions docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,26 +191,44 @@ Full form:
```yaml
auth:
pool: internal # 'internal' (default) or 'external'
callback_urls: # optional, https only
client: confidential # confidential (default) | public | none — see below
callback_urls: # optional; https, or http://localhost for local dev
- https://app.javazone.no/auth/callback
logout_urls: # optional, https only
- http://localhost:3000/auth/callback
logout_urls: # optional; same rules
- https://app.javazone.no/
scopes: # optional, default ['openid', 'email', 'profile']
- openid
- email
- profile
groups: # optional, reference-only — exposed as COGNITO_GROUPS env var
- admins
- editors
groups: # optional — only members of these groups can sign in
- helter
env: # optional — also inject values under your own names
COGNITO_CLIENT_ID: NUXT_PUBLIC_OIDC_CLIENT_ID
COGNITO_ISSUER_URL: NUXT_PUBLIC_OIDC_AUTHORITY
```

#### `client`

| Value | For | You get |
|---|---|---|
| `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 |

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.

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.
- `none` or omitted — no Cognito integration.

`auth:` requires `routing.host` — callback and logout URL defaults are derived from it.
`auth:` with a client needs `routing.host` or explicit `callback_urls` — the defaults are derived from the host.

#### Defaults

Expand All @@ -233,14 +251,21 @@ On the very first deploy with `auth:`, the first task can start a few seconds be
|---|---|---|
| `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_ISSUER_URL` | `https://cognito-idp.{region}.amazonaws.com/{pool_id}` | OIDC issuer for JWT validation (`{issuer}/.well-known/openid-configuration`) |
| `COGNITO_CLIENT_ID` | SSM (ECS task `secrets`) | The OAuth client ID |
| `COGNITO_CLIENT_SECRET` | SSM SecureString (ECS task `secrets`) | Always generated — server-side apps only |
| `COGNITO_GROUPS` | Comma-joined list from `auth.groups` | Optional; for app-level authorization on the `cognito:groups` JWT claim |
| `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 |
| `COGNITO_CLIENT_SECRET` | SSM SecureString (ECS task `secrets`) | `confidential` only |
| `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.

#### Groups

Tokens from the internal pool carry the user's registry groups in `cognito:groups` (ID and access token): every group in `javaBin/registry` `groups/groups.yaml` with `cognito: true` the user is a member of, plus `team-{team}` for teams they belong to. The `javabin-auth-claims` trigger adds them at every sign-in and refresh, from membership team-provisioner records on each registry merge. A registry change reaches tokens at the user's next sign-in or token refresh.

#### Groups are reference-only
`auth.groups` restricts the client: a user in none of the listed groups is refused at sign-in, so the app never receives a token for them. Apps should still check `cognito:groups` for finer-grained authorization (e.g. admin features).

`auth.groups` is a list of group names that the app *expects* to see on the `cognito:groups` JWT claim. **No Cognito groups are created or modified from `app.yaml`.** Groups are managed by the team-provisioner Lambda (e.g. `team-{team}` groups synced from the registry).
No Cognito groups are created from `app.yaml`; add groups in the registry.

#### `pool: both` is not supported

Expand Down
11 changes: 10 additions & 1 deletion docs/lambda-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,21 @@ 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, https URLs, scopes) → upsert client `{team}-{app}` in the requested pool with every pool IdP + `COGNITO` → remove it from the other pool → write `/javabin/apps/{team}/{app}/cognito-client-id` (String) and `cognito-client-secret` (SecureString), tagged `team={team}`. 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 + `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.

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

**Manual run:** `aws lambda invoke --function-name javabin-app-auth-sync --payload '{"action":"reconcile"}' --cli-binary-format raw-in-base64-out /dev/stdout`

## auth-claims

**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.

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

## plan-review

**Trigger:** Invoked by app `tf-plan` (via the `javabin-ci-app-broker` OIDC role) after the plan is uploaded
Expand Down
4 changes: 2 additions & 2 deletions docs/reusable-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,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), `callback_urls`, `logout_urls`, `allowed_oauth_scopes`, `external_pool_custom_domain`
**Outputs:** `user_pool_id`, `user_pool_domain`, `issuer_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`
**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`

## Policy Composition Pattern

Expand Down
69 changes: 50 additions & 19 deletions scripts/generate-modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class AppYamlError(ValueError):
"routing": {"host", "priority", "deregistration_delay"},
"domain": None,
"resources": {"buckets", "databases", "secrets", "queues"},
"auth": {"pool", "callback_urls", "logout_urls", "scopes", "groups"},
"auth": {"pool", "client", "callback_urls", "logout_urls", "scopes", "groups", "env"},
"environment": None,
"alarms": {"enabled", "cpu_threshold", "memory_threshold", "error_5xx_threshold"},
}
Expand All @@ -84,6 +84,8 @@ class AppYamlError(ValueError):
"budget_alert_nok": "budgets are per team — set budget_nok in javaBin/registry teams/<team>.yaml",
}

AUTH_CLIENT_TYPES = ("confidential", "public", "none")

NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,19}$")
TEAM_RE = re.compile(r"^[a-z]{1,12}$")
RESOURCE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
Expand Down Expand Up @@ -404,37 +406,66 @@ def src(mod):
if pool not in ("internal", "external"):
raise AppYamlError(f"auth.pool must be 'internal' or 'external' (got {pool!r})")

host = routing.get("host")
if not host:
raise AppYamlError("auth: requires routing.host (callback URLs default to it)")
client = auth.get("client", "confidential")
if client not in AUTH_CLIENT_TYPES:
raise AppYamlError(f"auth.client must be one of {', '.join(AUTH_CLIENT_TYPES)} (got {client!r})")

callback_urls = auth.get("callback_urls") or [
f"https://{host}/",
f"https://{host}/auth/callback",
]
logout_urls = auth.get("logout_urls") or [f"https://{host}/"]
groups = auth.get("groups") or []
if not isinstance(groups, list) or not all(isinstance(g, str) and g for g in groups):
raise AppYamlError("auth.groups must be a list of group names")

auth_attrs = {
"app_name": name,
"team": team,
"pool": pool,
"callback_urls": callback_urls,
"logout_urls": logout_urls,
"client_type": client,
"project": PROJECT,
}
if client != "none":
host = routing.get("host")
if not host and not auth.get("callback_urls"):
raise AppYamlError("auth: requires routing.host or auth.callback_urls (callback URLs default to the host)")
auth_attrs["callback_urls"] = auth.get("callback_urls") or [
f"https://{host}/",
f"https://{host}/auth/callback",
]
auth_attrs["logout_urls"] = auth.get("logout_urls") or ([f"https://{host}/"] if host else [])
if groups:
auth_attrs["allowed_groups"] = groups
if auth.get("scopes"):
auth_attrs["allowed_oauth_scopes"] = auth["scopes"]

blocks.append(module_block("auth", src("cognito-app-client"), auth_attrs))

env_map["COGNITO_USER_POOL_ID"] = Ref("module.auth.user_pool_id")
env_map["COGNITO_DOMAIN"] = Ref("module.auth.user_pool_domain")
env_map["COGNITO_ISSUER_URL"] = Ref("module.auth.issuer_url")
secret_map["COGNITO_CLIENT_ID"] = Ref("module.auth.client_id_arn")
secret_map["COGNITO_CLIENT_SECRET"] = Ref("module.auth.client_secret_arn")

if auth.get("groups"):
env_map["COGNITO_GROUPS"] = ",".join(auth["groups"])
auth_env = {
"COGNITO_USER_POOL_ID": Ref("module.auth.user_pool_id"),
"COGNITO_DOMAIN": Ref("module.auth.user_pool_domain"),
"COGNITO_ISSUER_URL": Ref("module.auth.issuer_url"),
"COGNITO_WELL_KNOWN_URL": Ref("module.auth.well_known_url"),
}
if groups:
auth_env["COGNITO_GROUPS"] = ",".join(groups)
auth_secrets = {}
if client != "none":
auth_secrets["COGNITO_CLIENT_ID"] = Ref("module.auth.client_id_arn")
if client == "confidential":
auth_secrets["COGNITO_CLIENT_SECRET"] = Ref("module.auth.client_secret_arn")

# auth.env: expose the same values under names the app already reads,
# e.g. {COGNITO_CLIENT_ID: NUXT_PUBLIC_OIDC_CLIENT_ID}
aliases = auth.get("env") or {}
if not isinstance(aliases, dict):
raise AppYamlError("auth.env must map injected names to extra variable names")
for source, alias in aliases.items():
if source not in auth_env and source not in auth_secrets:
available = ", ".join(sorted({*auth_env, *auth_secrets}))
raise AppYamlError(f"auth.env: {source} is not injected for this app (available: {available})")
if not ENV_RE.match(str(alias)):
raise AppYamlError(f"auth.env.{source}: {alias!r} is not a valid variable name")
(auth_env if source in auth_env else auth_secrets)[alias] = (auth_env | auth_secrets)[source]

env_map.update(auth_env)
secret_map.update(auth_secrets)

# Task role (always)
blocks.append(module_block("task_role", src("service-role"), {
Expand Down
46 changes: 45 additions & 1 deletion scripts/tests/test_generate_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,56 @@ def test_worker_without_routing_has_no_target_group(self):
self.assertNotIn('module "alarms"', main)


class AuthFeaturesTest(unittest.TestCase):
def test_public_client_gets_id_but_no_secret(self):
app = {**FROSTING, "auth": {**FROSTING["auth"], "client": "public"}}
main = generate(app)["main.tf"]
self.assertIn('"public"', main)
self.assertIn("COGNITO_CLIENT_ID", main)
self.assertNotIn("COGNITO_CLIENT_SECRET", main)

def test_validate_only_injects_issuer_without_client_or_host(self):
app = {"name": "api", "team": "core", "auth": {"pool": "internal", "client": "none"}}
main = generate(app)["main.tf"]
self.assertIn("module.auth.well_known_url", main)
self.assertNotIn("COGNITO_CLIENT_ID", main)
self.assertNotIn("callback_urls", main)

def test_env_aliases_expose_values_under_app_names(self):
app = {**FROSTING, "auth": {"pool": "internal", "client": "public",
"env": {"COGNITO_CLIENT_ID": "NUXT_PUBLIC_OIDC_CLIENT_ID",
"COGNITO_ISSUER_URL": "NUXT_PUBLIC_OIDC_AUTHORITY"}}}
main = generate(app)["main.tf"]
self.assertRegex(main, r"NUXT_PUBLIC_OIDC_CLIENT_ID\s+= module.auth.client_id_arn")
self.assertRegex(main, r"NUXT_PUBLIC_OIDC_AUTHORITY\s+= module.auth.issuer_url")

def test_alias_of_value_not_injected_is_rejected(self):
app = {**FROSTING, "auth": {"pool": "internal", "client": "public",
"env": {"COGNITO_CLIENT_SECRET": "SECRET"}}}
with self.assertRaises(gen.AppYamlError) as cm:
generate(app)
self.assertIn("not injected", str(cm.exception))

def test_groups_restrict_the_client(self):
app = {**CUPCAKE, "auth": {"pool": "internal", "groups": ["helter"]}}
main = generate(app)["main.tf"]
self.assertIn('allowed_groups', main)
self.assertIn('["helter"]', main)

def test_localhost_callbacks_are_passed_through(self):
app = {**FROSTING, "auth": {"pool": "internal", "client": "public",
"callback_urls": ["https://cupcake.javazone.no/", "http://localhost:3000/"]}}
self.assertIn('"http://localhost:3000/"', generate(app)["main.tf"])


@unittest.skipUnless(shutil.which("terraform"), "terraform not installed")
class FmtTest(unittest.TestCase):
"""tf-plan runs `terraform fmt -check`; generated files must pass it."""

def test_generated_files_are_fmt_clean(self):
for app in (CUPCAKE, FROSTING, FULL, {"name": "worker", "team": "core"}):
public = {**FROSTING, "auth": {"pool": "internal", "client": "public", "groups": ["helter"],
"env": {"COGNITO_CLIENT_ID": "NUXT_PUBLIC_OIDC_CLIENT_ID"}}}
for app in (CUPCAKE, FROSTING, FULL, public, {"name": "worker", "team": "core"}):
with tempfile.TemporaryDirectory() as d:
for name, content in generate(app).items():
with open(os.path.join(d, name), "w") as f:
Expand Down
Loading
Loading