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.
aws_lambda_function—nodejs22.x, handlerindex.handler. Bundled by esbuild (console/api/build.mjs) at apply and zipped fromdist/.aws_apigatewayv2_api+ integration +$defaultroute + 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:DescribeTableon every table in the account (see Checking a table exists), and — both off by default —sts:AssumeRoleonassumable_role_arnsplus item-level DynamoDB access ontarget_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>-targetsunlesstargets_table_nameoverrides it, and passed to the Lambda asTARGETS_TABLE_NAME. Separate from every rules table. Deletion protection is on viadeletion_protection(defaulttrue) — the table is the only record of which rules table each target points at, so turn it off deliberately or not at all.
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.
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.
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:GetDistributionConfigon this account's distributions (arn:<partition>:cloudfront::<account>:distribution/*), or onreadable_distribution_arnswhen 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:GetCachePolicyandcloudfront:GetOriginRequestPolicyon*. 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.
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_UNSAFEwhen a behavior that can serve it caches withoutCloudFront-Viewer-Countryin its cache key — the rewritten page would be served to every country. There is no override: fix the distribution. -
409 GEO_UNVERIFIEDwhen the distribution could not be read. Repeat with?confirmUnverifiedGeo=trueto 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_FORWARDEDfor 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). TheCloudFront-*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 wholeCookieheader, so its name is read off the value —beta=1needsbeta(compared regardless of case unless the condition is case-sensitive); a regex, a wildcard or a bare word such aspremiumneeds every cookie sent on. An unreadable distribution isGEO_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.
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"]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"
}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 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.
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.
- Cross-account targets — the
roleArnshape 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.
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"}.
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.