diff --git a/docs/app-yaml-reference.md b/docs/app-yaml-reference.md index 45b1b3a..726f568 100644 --- a/docs/app-yaml-reference.md +++ b/docs/app-yaml-reference.md @@ -167,17 +167,69 @@ resources: ### auth -Cognito user pool client registration. Creates app credentials and stores them in SSM at `/{project}/platform-apps/{name}/cognito-{pool}-client-{id,secret}`. +Registers the app as a Cognito client in the platform's internal or external user pool, stores the credentials in SSM, and injects them into the container as environment variables. + +Shorthand: + +```yaml +auth: internal # uses defaults below +``` + +Full form: ```yaml -auth: internal # options: internal, external, both, none +auth: + pool: internal # 'internal' or 'external' (see "pool: both" below) + callback_urls: # optional + - https://app.javazone.no/auth/callback + logout_urls: # optional + - 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 ``` -- `internal` — Javabin members (Google Workspace SSO) -- `external` — public users (self-registration) -- `both` — creates clients in both pools +A client secret is always generated — every app on this platform is a +server-side container. + +- `internal` — Javabin members (Google Workspace SSO via SAML) +- `external` — public users (Google OAuth, self-registration) - `none` or omitted — no Cognito integration +`auth:` requires `routing.host` — callback and logout URL defaults are derived from it. + +#### Defaults + +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}/]`. + +#### Injected environment variables + +When `auth:` is set, these env vars are injected into the container automatically: + +| Var | Source | Notes | +|---|---|---| +| `COGNITO_USER_POOL_ID` | Looked up from the platform pool | 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` (internal); 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 | +| `COGNITO_CLIENT_ID` | SSM SecureString (via ECS task `secrets`) | The OAuth client ID | +| `COGNITO_CLIENT_SECRET` | SSM SecureString (via ECS task `secrets`) | Always generated — server-side apps only | +| `COGNITO_GROUPS` | Comma-joined list from `auth.groups` | Optional; for app-level authorization on `cognito:groups` JWT claim | + +The credentials live in SSM at `/{project}/platform-apps/{name}/cognito-{pool}-client-{id,secret}`. The shared ECS execution role has read access to that path. + +#### Groups are reference-only + +`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 themselves are managed by the team-provisioner Lambda (e.g. `team-{team}` groups synced from the registry). If you need a new group, add it via the registry or Cognito console out-of-band, then list it here so the app can read `COGNITO_GROUPS` for authorization checks. + +#### `pool: both` is not yet supported + +The generator rejects `auth.pool: both`. Apps that need to accept users from both pools should split into two clients with explicit env-var naming — defer this until a concrete need lands. + ### domain Convenience alias for `routing.host`. If both are set, `routing.host` takes precedence. diff --git a/scripts/generate-modules.py b/scripts/generate-modules.py index 0db4596..f96dab3 100755 --- a/scripts/generate-modules.py +++ b/scripts/generate-modules.py @@ -253,6 +253,55 @@ def src(mod): if queue.get("env"): env_map[queue["env"]] = f"module.{mod_name}.queue_url" + # Auth (Cognito app client) — optional + auth_raw = app.get("auth") + if auth_raw and auth_raw != "none": + # Accept shorthand: auth: internal -> {pool: internal} + auth = {"pool": auth_raw} if isinstance(auth_raw, str) else auth_raw + pool = auth.get("pool") + if pool == "both": + raise NotImplementedError( + "auth.pool: 'both' is not yet supported — pick 'internal' or 'external'. " + "When the first concrete need lands, decide on COGNITO_INTERNAL_*/COGNITO_EXTERNAL_* env-var conventions." + ) + if pool not in ("internal", "external"): + raise ValueError(f"auth.pool must be 'internal' or 'external' (got {pool!r})") + + host = routing.get("host") + if not host: + raise ValueError("auth: requires routing.host (callback URLs default to it)") + + 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}/"] + + auth_attrs = { + "app_name": name, + "team": team, + "pool": pool, + "callback_urls": callback_urls, + "logout_urls": logout_urls, + "project": PROJECT, + } + if auth.get("scopes"): + auth_attrs["allowed_oauth_scopes"] = auth["scopes"] + if auth.get("external_pool_custom_domain"): + auth_attrs["external_pool_custom_domain"] = auth["external_pool_custom_domain"] + + blocks.append(module_block("auth", src("cognito-app-client"), auth_attrs)) + policy_map["auth"] = "module.auth.access_policy_json" + + env_map["COGNITO_USER_POOL_ID"] = f"module.auth.{pool}_user_pool_id" + env_map["COGNITO_DOMAIN"] = f"module.auth.{pool}_user_pool_domain" + env_map["COGNITO_ISSUER_URL"] = f"module.auth.{pool}_issuer_url" + secret_map["COGNITO_CLIENT_ID"] = f"module.auth.{pool}_client_id_arn" + secret_map["COGNITO_CLIENT_SECRET"] = f"module.auth.{pool}_client_secret_arn" + + if auth.get("groups"): + env_map["COGNITO_GROUPS"] = ",".join(auth["groups"]) + # Task role (always) blocks.append(module_block("task_role", src("service-role"), { "name": name, diff --git a/scripts/registry.py b/scripts/registry.py index a69f9d4..b884b98 100644 --- a/scripts/registry.py +++ b/scripts/registry.py @@ -18,17 +18,18 @@ # --------------------------------------------------------------------------- MODULE_SOURCES = { - "platform-data": "terraform/modules/platform-data", - "ecr-repo": "terraform/modules/ecr-repo", - "service-routing": "terraform/modules/service-routing", - "service-role": "terraform/modules/service-role", - "ecs-service": "terraform/modules/ecs-service", - "service-alarm": "terraform/modules/service-alarm", - "service-bucket": "terraform/modules/service-bucket", - "service-database": "terraform/modules/service-database", - "service-rds": "terraform/modules/service-rds", - "service-secret": "terraform/modules/service-secret", - "service-queue": "terraform/modules/service-queue", + "platform-data": "terraform/modules/platform-data", + "ecr-repo": "terraform/modules/ecr-repo", + "service-routing": "terraform/modules/service-routing", + "service-role": "terraform/modules/service-role", + "ecs-service": "terraform/modules/ecs-service", + "service-alarm": "terraform/modules/service-alarm", + "service-bucket": "terraform/modules/service-bucket", + "service-database": "terraform/modules/service-database", + "service-rds": "terraform/modules/service-rds", + "service-secret": "terraform/modules/service-secret", + "service-queue": "terraform/modules/service-queue", + "cognito-app-client": "terraform/modules/cognito-app-client", } # --------------------------------------------------------------------------- diff --git a/terraform/modules/cognito-app-client/main.tf b/terraform/modules/cognito-app-client/main.tf index 4b065fa..c2d2e4c 100644 --- a/terraform/modules/cognito-app-client/main.tf +++ b/terraform/modules/cognito-app-client/main.tf @@ -1,13 +1,48 @@ ################################################################################ -# Cognito App Client — registers an app with internal/external user pools +# Cognito App Client — registers an app with internal/external user pools. # -# Creates Cognito user pool clients and stores credentials in SSM so the +# The module looks up its target pool by NAME (not ID), so callers don't need +# to plumb pool IDs through every layer. Pool names are unique by convention +# in this account (one pool per role: javabin-internal, javabin-external). +# +# Creates an aws_cognito_user_pool_client and stores credentials in SSM so the # app can read them at runtime without hardcoding. ################################################################################ +data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + +data "aws_cognito_user_pools" "internal" { + count = local.create_internal ? 1 : 0 + name = var.internal_pool_name +} + +data "aws_cognito_user_pools" "external" { + count = local.create_external ? 1 : 0 + name = var.external_pool_name +} + locals { create_internal = var.pool == "internal" || var.pool == "both" create_external = var.pool == "external" || var.pool == "both" + + region = data.aws_region.current.region + account_id = data.aws_caller_identity.current.account_id + + internal_pool_id = local.create_internal ? tolist(data.aws_cognito_user_pools.internal[0].ids)[0] : "" + external_pool_id = local.create_external ? tolist(data.aws_cognito_user_pools.external[0].ids)[0] : "" + + internal_pool_arn = local.create_internal ? "arn:aws:cognito-idp:${local.region}:${local.account_id}:userpool/${local.internal_pool_id}" : "" + external_pool_arn = local.create_external ? "arn:aws:cognito-idp:${local.region}:${local.account_id}:userpool/${local.external_pool_id}" : "" + + # Internal pool always has a Cognito-managed domain (deterministic FQDN). + # External pool's domain is custom (auth-external.) and only exists + # when a certificate is wired up at the platform level — caller passes it in. + internal_pool_domain = local.create_internal ? "${var.internal_pool_name}.auth.${local.region}.amazoncognito.com" : "" + external_pool_domain = local.create_external ? var.external_pool_custom_domain : "" + + internal_issuer_url = local.create_internal ? "https://cognito-idp.${local.region}.amazonaws.com/${local.internal_pool_id}" : "" + external_issuer_url = local.create_external ? "https://cognito-idp.${local.region}.amazonaws.com/${local.external_pool_id}" : "" } ################################################################################ @@ -17,15 +52,8 @@ locals { resource "aws_cognito_user_pool_client" "internal" { count = local.create_internal ? 1 : 0 - name = var.app_name - user_pool_id = var.internal_user_pool_id - - lifecycle { - precondition { - condition = var.internal_user_pool_id != "" - error_message = "internal_user_pool_id must be set when pool is \"internal\" or \"both\"." - } - } + name = "${var.team}-${var.app_name}" + user_pool_id = local.internal_pool_id generate_secret = var.generate_secret @@ -66,15 +94,8 @@ resource "aws_ssm_parameter" "internal_client_secret" { resource "aws_cognito_user_pool_client" "external" { count = local.create_external ? 1 : 0 - name = var.app_name - user_pool_id = var.external_user_pool_id - - lifecycle { - precondition { - condition = var.external_user_pool_id != "" - error_message = "external_user_pool_id must be set when pool is \"external\" or \"both\"." - } - } + name = "${var.team}-${var.app_name}" + user_pool_id = local.external_pool_id generate_secret = var.generate_secret @@ -107,3 +128,26 @@ resource "aws_ssm_parameter" "external_client_secret" { type = "SecureString" value = aws_cognito_user_pool_client.external[0].client_secret } + +################################################################################ +# IAM Policy Document — grants ssm:GetParameter[s] over the parameters created. +# Attach to the app's task role via the module's `access_policy_json` output. +################################################################################ + +locals { + ssm_arns = concat( + local.create_internal ? [aws_ssm_parameter.internal_client_id[0].arn] : [], + local.create_internal && var.generate_secret ? [aws_ssm_parameter.internal_client_secret[0].arn] : [], + local.create_external ? [aws_ssm_parameter.external_client_id[0].arn] : [], + local.create_external && var.generate_secret ? [aws_ssm_parameter.external_client_secret[0].arn] : [], + ) +} + +data "aws_iam_policy_document" "access" { + statement { + sid = "CognitoSSMRead" + effect = "Allow" + actions = ["ssm:GetParameter", "ssm:GetParameters"] + resources = local.ssm_arns + } +} diff --git a/terraform/modules/cognito-app-client/outputs.tf b/terraform/modules/cognito-app-client/outputs.tf index 2c5dcf6..9d6b29f 100644 --- a/terraform/modules/cognito-app-client/outputs.tf +++ b/terraform/modules/cognito-app-client/outputs.tf @@ -1,3 +1,51 @@ +################################################################################ +# User pool metadata (looked up by name, exposed for app config) +################################################################################ + +output "internal_user_pool_id" { + description = "ID of the internal Cognito user pool (empty if pool != internal/both)" + value = local.internal_pool_id +} + +output "internal_user_pool_arn" { + description = "ARN of the internal Cognito user pool" + value = local.internal_pool_arn +} + +output "internal_user_pool_domain" { + description = "Hosted-UI FQDN for the internal pool (empty if pool != internal/both)" + value = local.internal_pool_domain +} + +output "internal_issuer_url" { + description = "OIDC issuer URL for the internal pool — for backend JWT validation" + value = local.internal_issuer_url +} + +output "external_user_pool_id" { + description = "ID of the external Cognito user pool (empty if pool != external/both)" + value = local.external_pool_id +} + +output "external_user_pool_arn" { + description = "ARN of the external Cognito user pool" + value = local.external_pool_arn +} + +output "external_user_pool_domain" { + description = "Custom domain for the external pool (empty when no custom domain configured)" + value = local.external_pool_domain +} + +output "external_issuer_url" { + description = "OIDC issuer URL for the external pool" + value = local.external_issuer_url +} + +################################################################################ +# Client credentials +################################################################################ + output "internal_client_id" { description = "Internal Cognito app client ID (empty if pool != internal/both)" value = local.create_internal ? aws_cognito_user_pool_client.internal[0].id : "" @@ -8,12 +56,36 @@ output "external_client_id" { value = local.create_external ? aws_cognito_user_pool_client.external[0].id : "" } +output "internal_client_id_arn" { + description = "ARN of the SSM parameter holding the internal client ID (empty when pool != internal/both)" + value = local.create_internal ? aws_ssm_parameter.internal_client_id[0].arn : "" +} + +output "internal_client_secret_arn" { + description = "ARN of the SSM parameter holding the internal client secret (empty when not generated)" + value = local.create_internal && var.generate_secret ? aws_ssm_parameter.internal_client_secret[0].arn : "" +} + +output "external_client_id_arn" { + description = "ARN of the SSM parameter holding the external client ID (empty when pool != external/both)" + value = local.create_external ? aws_ssm_parameter.external_client_id[0].arn : "" +} + +output "external_client_secret_arn" { + description = "ARN of the SSM parameter holding the external client secret (empty when not generated)" + value = local.create_external && var.generate_secret ? aws_ssm_parameter.external_client_secret[0].arn : "" +} + +################################################################################ +# IAM +################################################################################ + output "ssm_parameter_arns" { - description = "ARNs of all SSM parameters created (for IAM policy scoping)" - value = concat( - local.create_internal ? [aws_ssm_parameter.internal_client_id[0].arn] : [], - local.create_internal && var.generate_secret ? [aws_ssm_parameter.internal_client_secret[0].arn] : [], - local.create_external ? [aws_ssm_parameter.external_client_id[0].arn] : [], - local.create_external && var.generate_secret ? [aws_ssm_parameter.external_client_secret[0].arn] : [], - ) + description = "All SSM parameters created — kept for callers that want the bag (IAM policy scoping, etc.)" + value = local.ssm_arns +} + +output "access_policy_json" { + description = "IAM policy JSON granting ssm:GetParameter[s] over the cognito SSM params — attach to task role" + value = data.aws_iam_policy_document.access.json } diff --git a/terraform/modules/cognito-app-client/variables.tf b/terraform/modules/cognito-app-client/variables.tf index b385d0c..54e44a7 100644 --- a/terraform/modules/cognito-app-client/variables.tf +++ b/terraform/modules/cognito-app-client/variables.tf @@ -3,6 +3,11 @@ variable "app_name" { type = string } +variable "team" { + description = "Team that owns the app — used as a prefix on the Cognito client name to avoid cross-team collisions" + type = string +} + variable "pool" { description = "Which Cognito pool(s) to register with: 'internal', 'external', or 'both'" type = string @@ -13,14 +18,20 @@ variable "pool" { } } -variable "internal_user_pool_id" { - description = "Cognito User Pool ID for internal (employee/hero) users" +variable "internal_pool_name" { + description = "Name of the internal Cognito user pool (looked up by name)" type = string - default = "" + default = "javabin-internal" +} + +variable "external_pool_name" { + description = "Name of the external Cognito user pool (looked up by name)" + type = string + default = "javabin-external" } -variable "external_user_pool_id" { - description = "Cognito User Pool ID for external (public) users" +variable "external_pool_custom_domain" { + description = "Custom domain registered on the external pool (e.g. auth-external.javazone.no). Empty if no custom domain is configured — COGNITO_DOMAIN will be empty for external apps." type = string default = "" } diff --git a/terraform/platform/iam/main.tf b/terraform/platform/iam/main.tf index 1cbf57f..508c8c0 100644 --- a/terraform/platform/iam/main.tf +++ b/terraform/platform/iam/main.tf @@ -785,6 +785,10 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" { name = "secrets-read" role = aws_iam_role.ecs_execution.id + # Covers both: + # //apps///... — service-secret SSM params + # //platform-apps//... — cognito-app-client SSM params + # The execution role fetches both at task launch via the ECS `secrets` block. policy = jsonencode({ Version = "2012-10-17" Statement = [ @@ -794,7 +798,10 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" { Action = [ "ssm:GetParameters", ] - Resource = "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/*" + Resource = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/*", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform-apps/*", + ] } ] })