Skip to content

Latest commit

 

History

History

infra

README.md

console API — infrastructure

Terraform for the control-plane API: an HTTP API Gateway (v2) fronting a single Node 22 Lambda that runs the request router in console/api/src.

What it creates

  • aws_lambda_function — nodejs22.x, handler index.handler. Bundled by esbuild (console/api/build.mjs) at apply and zipped from dist/.
  • aws_apigatewayv2_api + integration + $default route + stage — an HTTP API with a catch-all route; the Lambda's own router dispatches every path.
  • aws_iam_role — execution role: CloudWatch Logs, read/write on the targets registry table, dynamodb:DescribeTable on every table in the account (see Checking a table exists), and — both off by default — sts:AssumeRole on assumable_role_arns plus item-level DynamoDB access on target_table_arns (see Reaching a target's table), and read-only CloudFront configuration access, scoped to this account's distributions (see Checking a distribution's cache settings).
  • aws_cloudwatch_log_group — /aws/lambda/<function_name>.
  • aws_dynamodb_table (targets registry) — the control-plane's own state (pk=id, PAY_PER_REQUEST, PITR on). Named <function_name>-targets unless targets_table_name overrides it, and passed to the Lambda as TARGETS_TABLE_NAME. Separate from every rules table. Deletion protection is on via deletion_protection (default true) — the table is the only record of which rules table each target points at, so turn it off deliberately or not at all.

Reaching a target's table

Targets are registered at runtime; IAM is granted at apply time. Those two facts do not meet on their own: a DynamoDB ARN is arn:aws:dynamodb:<region>:<account>:table/<name>, so a static policy has to enumerate every table before it exists.

Two variables bridge that, and both are empty by default — so out of the box no target is reachable:

Approach Self-service Least-privilege
Enumerate table ARNs — target_table_arns no — a Terraform apply per target yes
Wildcard on a table-name prefix — also target_table_arns yes no — any matching table in the account
Per-target roleArn — assumable_role_arns yes yes

target_table_arns grants the API's own execution role access to the tables you name. A target with no roleArn uses those credentials, so it is reachable only if its table is listed:

target_table_arns = ["arn:aws:dynamodb:us-east-1:123456789012:table/edgeroute-rules-*"]

assumable_role_arns is the self-service route. A target carries a roleArn and the API assumes it to reach that target's table, so registering a new target needs no Terraform change:

assumable_role_arns = ["arn:aws:iam::123456789012:role/edgeroute-target-*"]

Each target's role needs a trust policy admitting this Lambda's execution role and a permissions policy covering its own rules table. Mirror the actions the execution role gets on target_table_arns — in particular dynamodb:BatchWriteItem, which deleting a host needs and which dynamodb:DeleteItem does not imply.

That permissions policy should also include dynamodb:DescribeTable, for the reason in the next section — and on a resource pattern wide enough to cover a name that was typed wrong, not just the one table that should exist.

For the country-condition warning, it should also allow cloudfront:GetDistributionConfig, cloudfront:GetCachePolicy and cloudfront:GetOriginRequestPolicy — see Checking a distribution's cache settings.

Both variables are validated, and the rules are the same for each: the account must be literal, and the role or table name must be literal apart from an optional trailing *. ? is rejected anywhere, because IAM treats it as a single-character wildcard. target_table_arns additionally requires a non-empty region, though the region may itself be *. So:

ARN
…:role/edgeroute-target-* accepted
…dynamodb:*:1234…:table/edgeroute-rules-* accepted — region wildcards are fine
* rejected
…iam::*:role/* rejected — account is not literal
…:role/*, …:table/* rejected — that is every role or table in the account
…:role/?????????? rejected — ? is a wildcard too
…:role/edge-*-prod rejected — the * must be last
…dynamodb::1234…:table/x rejected — empty region matches nothing

Leave both empty and no target is reachable, so rule operations (CF-13) will fail. That is the default deliberately — neither grant should be implicit.

The reason roleArn exists now rather than later is the runtime-vs-apply-time gap above, not cross-account support: the scope doc puts cross-account under "not in the 30 days", and nothing here has been exercised against a second account. What the field does buy is that adding it later, after the console generates a typed client from these additionalProperties: false schemas, would be a breaking change.

Checking a table exists

Registering a target describes its table first, so a mistyped name is refused with a 400 instead of being stored as a second entry that only fails on the first rules request. DynamoDB table names are case-sensitive, so Edgeroute-rules is not a duplicate of edgeroute-rules — nothing else catches it.

Only a table AWS positively reports as absent is refused. Any other failure means the API could not determine anything, and the registration is allowed: targets are registered at runtime while IAM is granted at apply time, so a target that is unreachable at registration is normal and must not be blocked. Those still surface later as a 502 TARGET_UNREACHABLE.

That is why the module grants dynamodb:DescribeTable on every table in the account (arn:<partition>:dynamodb:*:<account>:table/*) rather than on target_table_arns. IAM answers a denied call before DynamoDB can say whether the table is there, so a grant scoped to the tables that are supposed to exist denies exactly the mistyped names the check exists to catch — each one arriving as "cannot tell" and being allowed through. The check would be silently off. It is read-only metadata, no item data and no mutation, and it is unconditional because a fresh deployment has no target_table_arns yet.

The same logic applies to a target with a roleArn, whose table is described under that role: if its policy names the table exactly, a typo reads as AccessDenied and the check is inert for that target. Widen the resource pattern there to get it back.

When the check cannot run, the Lambda logs console-api: could not verify table … — registering it unchecked. A steady stream of those in CloudWatch means the typo check is not doing anything.

Checking a distribution's cache settings

A country condition is evaluated at origin-request, so the edge only sees cache misses. On a behavior that caches without CloudFront-Viewer-Country in its cache key, one viewer's copy is served to the next: a geo redirect silently misses most viewers, and a geo rewrite serves one country's page to everyone. The edge cannot log what never reaches it, so POST /targets/{id}/geo-readiness reads the distribution instead. It is sent the rule being written (kind and matches) and answers both the reading — a verdict per behavior, in CloudFront's order — and the decision for that rule: which behaviors can serve it, from its path, and whether saving it is ok, warn, blocked or unverifiable. The editor shows it; the rule routes apply it.

The distribution is the one the target is named after: the console registers a target under the distribution ID (or ARN) it was connected with. A target with another name answers unknown.

A target can also name its redirect function, edgeFunctionArn — the edge module's viewer_request_lambda_arn output, qualified or not. With it, only the behaviors running that function are judged, and one running someone else's is notOurs. Without it, every behavior with a Lambda@Edge association counts.

The execution role gets two read-only statements:

  • cloudfront:GetDistributionConfig on this account's distributions (arn:<partition>:cloudfront::<account>:distribution/*), or on readable_distribution_arns when set. Scoped because a distribution's config includes its origin custom headers, which sometimes carry a shared secret for the origin. The API extracts the policy IDs and function associations and returns only a verdict; nothing else is kept, logged or sent to the browser.
  • cloudfront:GetCachePolicy and cloudfront:GetOriginRequestPolicy on *. They hold TTLs and header, cookie and query string names, no values, and the managed policies behaviors usually point at are AWS's, not this account's.

A target with a roleArn is read under that role, so its policy needs the same actions. The target's distribution is known there, so scope GetDistributionConfig to it: arn:aws:cloudfront::<account>:distribution/<ID>.

A distribution that cannot be read is unknown with a cause — accessDenied (the grants above), notFound, transient (throttling, a timeout: try again) or unexpected (see the logs) — and the Lambda logs console-api: could not read distribution … with the error. Only a reading is kept for a minute; an unknown answer is asked again next time.

The write guard

POST and PUT on a rule, and PATCH turning one back on, check a country rewrite the same way before writing it:

  • 409 GEO_REWRITE_UNSAFE when a behavior that can serve it caches without CloudFront-Viewer-Country in its cache key — the rewritten page would be served to every country. There is no override: fix the distribution.

  • 409 GEO_UNVERIFIED when the distribution could not be read. Repeat with ?confirmUnverifiedGeo=true to save it anyway; each such write is logged as {"event":"geo-unverified-confirmed", principal, targetId, host, sk, cause}, never with the rule's content.

  • 409 CONDITION_NOT_FORWARDED for any rewrite — with or without a country — with a header or cookie condition that holds when the value is absent (a negation, notEquals, a regex like ^$, a lone *) on a header or cookie a behavior serving it does not send on to origin-request. CloudFront drops it before the function runs, it reads as absent for every viewer, and the condition then holds for everyone. Add it to the origin request policy (or the cache key). The CloudFront-* headers CloudFront adds itself only count when a policy names them: "all viewer headers" does not include them. A cookie condition is tested against the whole Cookie header, so its name is read off the value — beta=1 needs beta (compared regardless of case unless the condition is case-sensitive); a regex, a wildcard or a bare word such as premium needs every cookie sent on. An unreadable distribution is GEO_UNVERIFIED, as above.

Redirects are never refused (a geo redirect is no-store: the same setup makes it miss viewers, not misdirect them), nor is a rule saved disabled. The check reads the same minute-old reading as the editor, and it happens at write time: changing a cache policy later does not re-check rules already saved.

Region validation

A target's region is checked against a list. By default that is the API's built-in list of commercial regions, which has two problems: it ages, so a newly-launched region is rejected even though the user owns a table there; and it includes opt-in regions the account may never have enabled, which are accepted and then fail later.

Set allowed_regions to the regions this deployment can actually reach and both go away — it is passed through as ALLOWED_REGIONS and replaces the built-in list:

allowed_regions = ["us-east-1", "eu-west-1"]

Build coupling

null_resource.build runs the dependency install followed by npm run build --workspace @cloudfront-redirect-rules/api from the repo root at apply; the archive_file data source is deferred (depends_on) so it zips dist/ only after that build.

It repackages when any of these change: src/**/*.ts, build.mjs, package.json, tsconfig.json (esbuild reads it), the root package-lock.json, or the shared rule schemas (esbuild inlines them, so a schema-only edit still changes the bundle).

npm_install_command (default npm ci) is the install step. npm ci deletes and reinstalls node_modules, so applying from a repo you also work in wipes your install. Set it to npm install to keep your working tree, or to "" to skip installing and build against whatever is already there:

module "console_api" {
  source              = "../console/api/infra"
  function_name       = "edgeroute-console-api"
  npm_install_command = "npm install"
}

Authentication

A Cognito user pool, a JWT authorizer on the HTTP API, and two groups — viewer and editor. The gateway rejects a request with no valid token before the Lambda runs; the Lambda's router rejects a viewer on a route marked write: true. /health and the three /auth/* routes are reachable without a token, the last three because they are what issues one.

cognito_domain_prefix is required and has no default: the prefix is globally unique across AWS, so any value shipped here would collide for the second person to apply this.

After terraform apply, ./seed-users.sh creates viewer@example.com and editor@example.com with generated passwords, printed once.

The sign-in page

The domain serves Cognito's managed login (managed_login_version = 2), styled to match the console by aws_cognito_managed_login_branding from the files in branding/: settings.json (colours, dark mode only), logo.svg and favicon.svg. Managed login needs the pool on the Essentials plan, which is what AWS defaults a new pool to and what user_pool_tier pins; its free tier covers 10,000 monthly active users with no expiry.

To change the look, edit settings.json — it is AWS's default style (DescribeManagedLoginBrandingByClient) with the console's colours swapped in, so its keys are the ones AWS accepts. Two things AWS enforces: keep whole numbers as integers (8, not 8.0, or every plan shows a change), and keep a logo between 1:1 and 4:1 wide. Cognito scales the logo to a fixed height, so the SVG's own padding sets its size on the page. The page's text cannot be changed.

The deploying role needs cognito-idp:Create, Describe, DescribeByClient, Update and DeleteManagedLoginBranding beside the user pool actions, and cognito-idp:ListUserPoolClients: the provider reads the style back by listing the pool's clients, so without it the first apply succeeds and every later plan fails.

Single sign-on

Off by default, and that default is deliberate. This is deployed into other people's accounts, so the identity provider is theirs to choose — and federated monthly active users are billed differently from ones signing in directly, which makes it their cost decision too. Unset, the pool's own username-and-password accounts are the only way in.

To add one, set identity_provider:

identity_provider = {
  name          = "Okta"          # the label on the sign-in button
  issuer        = "https://example.okta.com"
  client_id     = "..."
  client_secret = "..."
}

OIDC only. It covers Google, Okta, Auth0, Entra ID and Keycloak from those four inputs, because Cognito reads the endpoints from the issuer's discovery document; SAML would need metadata XML and certificate handling per provider.

The provider is offered alongside the pool's own accounts, not instead of them — a deployment with SSO usually still wants a break-glass login that does not depend on it. No console code changes either way: the hosted UI renders the button.

Not here yet

  • Cross-account targets — the roleArn shape supports it; nothing has been exercised against a second account.
  • SAML federation — OIDC covers the common providers; nothing has been exercised against a SAML IdP.

Usage

module "console_api" {
  source = "../console/api/infra"

  function_name = "edgeroute-console-api"
  tags          = { team = "edge" }
}

output "api_endpoint" {
  value = module.console_api.api_endpoint
}

terraform apply, then curl "$(terraform output -raw api_endpoint)/health" → {"status":"ok"}.

Tests

terraform test runs a mocked, plan-only suite (tests/api.tftest.hcl) — no npm, no AWS. It asserts the Lambda runtime/handler/sizing, the HTTP API shape, the invoke permission, the log group name, the registry table (including deletion protection) and its TARGETS_TABLE_NAME wiring, that both access grants are off by default and scoped to exactly the ARNs given when set, that a "*" assumable_role_arns is rejected, that the rules-table grant never includes DeleteTable, and that ALLOWED_REGIONS is passed through when set and omitted entirely when not.

For authentication it asserts that sign-up is admin-only, that the app client is confidential and code-flow only with USER_PASSWORD_AUTH absent, that both role groups exist, that the authorizer accepts exactly this pool and client, that $default requires a token while only /health and the /auth/* routes do not, that callback URLs must be https or localhost, and that no identity provider exists until one is configured — and that a configured one is offered alongside COGNITO rather than replacing it.