Edge routing on AWS CloudFront
Adapter version 0.6.1 (0.6.0 plus Host alignment on every router exit and a stack-named site-key secret). The product argument, the cloaking question and the comparison across CDNs are in the edge routing white paper.
The diagrams
The path a request takes
GET example.com/products/widget/api/*, /checkout … never reach our code and are never billed. Assets keep a 24 h edge cache.edge-router-lambda.cjsLambda@Edge · nodejs24.x · 192 MB · 30 s ceiling · 20 s watchdog- Every request: read the config from origin custom headers, deleting each one in the same pass.
- Ordinary browser, or a search crawler: return the request unchanged and stop here — no lookup, no call to NORG or AWS.
- Bot-shaped only: on a cold container, fetch the site key from the regional Secrets Manager replica (1.5 s) and then the policy feed from NORG (5 s). Each is cached on its own clock afterwards — the key for 15 min, the feed for 1 h, refreshed inline once stale.
- Then classify: the operator must be enabled, and the source address must verify against published CIDRs.
- On a match: fetch the mirror, passing one
X-Norg-Visitheader that describes the visit. The router sends no telemetry and waits on none.
put-secret-value.X-Norg-Edge, written private, no-store. Strip under 120 words → origin instead.X-Norg-EdgeAI crawler: structured, same URL, 200Where it runs
Everything is named us-east-1; only the heartbeat actually executes there. The router replicates to CloudFront's regional edge caches and runs in the one nearest the request, reading the site key from that region's replica.
edge-router-lambda.cjsLambda@Edge · origin request · every page requestheartbeat-lambda.cjsRegional Lambda · 128 MB · 10 sEventBridge Scheduler every 30 min → POST to NORG (3 s). Proves a zero-traffic install is alive.DIGESTS.json. Rollback = stack update naming the previous version. Propagation 5–15 min./aws/lambda/us-east-1.<fn>Created per region; streams appear nearest the edge that served the request.x-norg-disabled- What it is. An origin custom header is a name and value CloudFront attaches to every request it forwards to an origin. It is part of the distribution's configuration, not something a visitor sends. Ours is
x-norg-disabled, defaultfalse. - Where it lives. In the CloudFront console: your distribution → Origins → your origin → Custom headers. In the CloudFormation template it is the
OriginCustomHeaderslist on the origin, fed by the stack parameterEdgeDisabled, alongside the otherx-norg-*settings. It is entirely in your AWS account. NORG cannot flip it. - How the router reads it. On every page request the origin-request Lambda lifts each configuration header out of the origin's custom headers on the request it was handed and deletes it, so your web server never sees them.
x-norg-disabled: trueis the first check in its passthrough test, before the browser check, search-crawler detection, the site key or the policy feed, so the request is returned unchanged and CloudFront fetches your origin as if we were not installed. - How to flip it. Update the stack with
EdgeDisabled=true, or edit the header value in the console; set it back tofalsethe same way. Both are a distribution update, which propagates in 5–15 minutes. There is no instant off on CloudFront. The functions stay deployed, so turning it back on is the same edit in reverse.
- origin request
- The point where CloudFront runs code just before fetching from the origin, only on a request its cache cannot answer. It is the one trigger this install uses, and because no page is cached at the edge it fires on every page request.
- cache behaviour
- A CloudFront rule that matches a path pattern and says how to cache it and which functions to attach. An install is mostly cache behaviours; a behaviour with no function attached is how a path is excluded.
- origin custom header
- A header the distribution adds to every request it sends toward the origin. Non-secret configuration travels this way; the router reads each one and deletes it.
- container
- One warm copy of the Lambda. Its two caches, the site key and the policy feed, live only as long as it does. Cold means a fresh one with nothing cached yet.
- passthrough
- The router returning the request object unchanged, so CloudFront fetches your origin exactly as it would without us.
- policy feed
- The per-site list of crawler patterns, operator settings and verified address ranges the router fetches from NORG and caches for up to an hour. A human request never fetches it.
- mirror, NORG content service (the receptionist)
- The agent-facing copy of a page, and the NORG endpoint the router fetches it from, which holds the mirror cache and records the visit from the header the router sends.
- MCP
- Model Context Protocol, the endpoint an agent uses to query a site directly. Requests to MCP paths are the one thing the router relays to NORG with a body.
It must never break your site. Every failure path ends at your origin response. On this platform that is structurally easy: passthrough is the request object returned unchanged, so CloudFront fetches your origin exactly as it would without us.
Search engines see what humans see. Traditional search crawlers are returned the origin before the router classifies anything, with no lookup made, and no configuration reaches the search-engine check itself.
What runs, and when
Two artifacts: the router on origin request, and a scheduled heartbeat that exists only because Lambda@Edge has no cron. Nothing runs at viewer request and nothing runs at origin response.
| Artifact | Trigger | Runtime and limits | What it is for |
|---|---|---|---|
| edge-router-lambda.cjs | Origin request (every page request) | Lambda@Edge, nodejs24.x 192 MB, 30 s platform ceiling, 20 s internal watchdog | The whole decision sequence, the mirror fetch, the strip and the MCP relay. It sends no telemetry of its own: the visit travels as one header on the mirror fetch and the NORG content service records it. |
| heartbeat-lambda.cjs | EventBridge Scheduler, every 30 minutes | Regional Lambda (us-east-1), nodejs24.x 128 MB, 10 s | Proves a zero-traffic install is alive. Lambda@Edge has no cron of any kind, so liveness cannot come from the router. |
No page is cached at the edge, so every page request reaches the origin-request Lambda. It reads its configuration out of the origin custom headers (non-secret settings the distribution attaches to every request it sends toward the origin) and deletes them in the same pass. Then it looks at the user agent. An ordinary browser on an ordinary path is returned at once, and so is a traditional search crawler: no lookup, no call to NORG, no call to AWS, just the request handed back for CloudFront to fetch your origin as if we were not installed.
Only a bot-shaped request goes further. On a cold container it makes two lookups before it can decide: the site key from Secrets Manager, read from the replica in the region that ran the function, and then the policy feed, the per-site list of crawler patterns, operator settings and verified address ranges it fetches from NORG. Both are cached afterwards. It classifies, checks the operator is enabled, verifies the source address, and then either returns the request for CloudFront to send to your origin, returns a generated response it built from the mirror, or rewrites the request to point at the NORG content service (the receptionist) when the mirror is too large to inline. The mirror fetch carries one extra header describing the visit, and the content service records it; the router itself sends no telemetry and waits on none.
A request is treated as an ordinary browser only when its user agent is confidently one: a Mozilla/5.0 prefix, a real engine token, none of the words a crawler or scripted client carries, and no Signature-Agent header. Everything else, an absent user agent included, takes the full sequence. The two directions of error are not symmetric: a human wrongly treated as bot-shaped costs one policy lookup on a cold container and is then passed through anyway, while an agent wrongly treated as a browser would be handed your origin with the router never having looked. So the test is deliberately lopsided toward the second case never happening, and the set of requests that go further is a superset of anything the router might divert.
Configuration and the site key
Lambda@Edge supports no environment variables. That single AWS restriction shapes this entire area, and it is why the answer is not simply "put it in the environment like the heartbeat does".
The site id, the NORG API and receptionist URLs, the environment name and the feature flags arrive as origin custom headers on the distribution. readConfig lifts them into a plain object and deletes each one from request.origin.custom.customHeaders in the same pass, whether or not it held a value, so a half-configured install cannot leak through a header nobody recognised as populated.
That deletion is what keeps them off your own web server. The same scrub is applied again to whatever object is about to be returned, including on the error and oversized-response paths, so there is no exit from the function that carries a config header onward.
The key is not in the distribution. It lives in a Secrets Manager secret in your own account, created in us-east-1 alongside the function and replicated by the stack to the other twelve regions Lambda@Edge runs in, so the function reads it from the region it is running in rather than across the world. The distribution carries only that secret's ARN in the x-norg-secret-arn header, which is an address rather than a credential. The router fetches it lazily: a human request or a search crawler never fetches it at all, because the browser check runs first; a bot-shaped request on a cold container makes two lookups before its first classification, the key and then the policy feed. It is cached in the container for fifteen minutes. Reading it needs secretsmanager:GetSecretValue on that one ARN, granted by a single inline policy on each of the two execution roles, and every read is recorded in CloudTrail.
Rotation is one call, put-secret-value, with no redeploy. The edge picks the new value up within the cache window and the heartbeat on its next run. The fifteen-minute time-to-live is deliberate: the AWS reference pattern for this caches for the whole container lifetime, which means a rotated secret can stay live indefinitely on a warm container.
A health probe arrives as the request header x-norg-edge-check. Its value is compared against the probe token the distribution supplies in the x-norg-probe-token custom header, which authorises reading install status and nothing else. That token is derived from your site key rather than stored, so rotating the key rotates it too. The inbound header is captured and stripped in the same pass as the config headers, so a mistyped probe cannot reach your origin either. Ask us for your site's value; a self-service surface for it is not built yet.
Failure behaviour, and every bound that enforces it
One rule governs the code: it must never break the site. On this platform that is easier to guarantee than on most, because "serve the origin" is not a fetch the router performs. It is the request object, returned unchanged, for CloudFront to carry out.
A Cloudflare worker that decides to pass through has to fetch your origin itself, so a bug in that fetch is a bug in your site. The origin-request contract here is different: the function returns either a response or a request, and returning the request means "carry on as you would have without me". CloudFront then applies its own retries, its own origin timeouts and its own shield. The top-level catch returns exactly that.
| Bound | Value | What it protects |
|---|---|---|
| WATCHDOG_MS | 20,000 ms | Races the whole pipeline. Sits under the platform 30 s so a slow call degrades to your origin rather than becoming a 502. |
| PATTERN_FETCH_TIMEOUT_MS | 5,000 ms | The policy feed, fetched once per cold container and then cached for the hour. Only a bot-shaped request ever reaches this call. |
| Stale feed refresh | 2,000 ms | A container whose feed has aged past its TTL refreshes it inline on the next bot-shaped request and serves the stale copy if that budget expires. A human request never waits on it. |
| MIRROR_FETCH_TIMEOUT_MS | 4,000 ms | Reading a rendered page from the NORG content service. The visit header rides on this call. |
| CONTROL_CALL_TIMEOUT_MS | 3,000 ms | The scheduled heartbeat Lambda’s own POST, the one foreground call to NORG that is not a mirror fetch. |
| MCP_FORWARD_TIMEOUT_MS | 3,000 ms | The one request relayed to NORG with a body. |
| Secrets Manager fetch | 1,500 ms | Once per container, cached 15 minutes, read from the replica in the region that ran the function. Failure returns null, which means passthrough. |
| UNENTITLED_TTL_MS | 60,000 ms | How long a refused or unreachable feed verdict is remembered, so a paused install costs its visitors no blocking probe on every request. |
| PATTERN_DEFAULT_TTL_MS | 3,600,000 ms | How long a container serves its cached policy feed. |
| MAX_INLINE_MIRROR_BYTES | 870,400 bytes | Above this the mirror is served by repointing the origin at the NORG content service instead of generating a response. |
| MAX_GENERATED_BODY_BYTES | 921,600 bytes | CloudFront's own cap on a generated response. Exceeding it degrades to the origin, never to an error. |
| STRIP_WORD_FLOOR | 120 words | A strip that leaves less than this is discarded and the unmodified origin is returned. |
NORG unreachable or the feed refused: the container keeps its last known-good policy, and an install that has never authenticated does nothing at all. Secrets Manager unreachable: the key resolves to null and the request passes through. Mirror missing, too large, or too thin after stripping: your origin. Watchdog expiry: your origin. Anything thrown anywhere: your origin. There is no path that returns an error page of ours, and the router sends no telemetry of its own, so nothing about analytics can hold or fail a response.
The one failure the code cannot degrade past sits above it: if a region's Lambda@Edge concurrency quota is exhausted, CloudFront answers 503 for every page request in that region until load falls. The cost panel under Distribution shape says how the install surfaces that quota and what bounds it.
Distribution shape, caching and telemetry
An install is mostly cache behaviours. What they are, in what order, and which managed policies they use, decides both correctness and cost.
MCP paths (the Model Context Protocol endpoints an agent calls, such as /.well-known/mcp.json) first, then dynamic routes, then framework asset prefixes, then static suffixes, then the default. Order matters because CloudFront takes the first match: an asset suffix placed above a dynamic route would swallow it. MCP behaviours are separated so the default behaviour can run with request bodies switched off. Only the MCP paths need a body, and forwarding one everywhere would put your visitors' POST payloads through our function for no reason.
The default behaviour and the MCP behaviours use CloudFront's managed CachingDisabled policy, so nothing they serve is ever stored at the edge, and an origin request policy of allViewerAndWhitelistCloudFront, which is what makes the viewer geography headers available to the router. Dynamic carve-outs use CachingDisabled with AllViewerExceptHostHeader; the asset carve-outs keep a 24-hour default TTL, because an image is the same bytes for everyone.
The distinction in that origin request policy is not cosmetic. The plain AllViewer policy forwards the viewer's Host header to the origin, which breaks any host-routed origin outright: a live checkout path returned 403 until it was corrected. If you are adapting this by hand, that is the mistake to avoid.
The router is an origin-request function, and CloudFront only runs one on a request it cannot answer from its cache. Earlier versions kept human pages cacheable and partitioned the cache by caller so that agents still reached the router; that took two more functions and a custom cache policy, and each was a place to get wrong. A cache policy with a minimum TTL above zero, for instance, overrides private, no-store on CloudFront, which would have let one bot-shaped request pin your origin page in front of every crawler that followed.
Since 0.6.0 there is no page cache at the edge at all. Every page request reaches the router, a human is handed straight through, and your origin answers as it always did. The origin's own Cache-Control still reaches the browser untouched; only CloudFront stops holding a copy. A mirror is written private, no-store and is never stored anywhere on the path. So every agent visit invokes the router and is recorded exactly once, and there is no cache anywhere that could hand an agent page to a person.
Every page request is a Lambda@Edge invocation, search crawlers included, and your origin takes every page request it would have taken without a CDN cache in front of it. For a human request the invocation is a few milliseconds of string matching with no network call, and the asset and framework carve-outs keep the bulk of a page's requests off the function entirely.
The ceiling is the regional Lambda@Edge concurrency quota, which is shared with every other Lambda you run in that region and defaults to 1,000 (often 10 on a new account); each instance serves ten requests a second, so the quota times ten is the page-request rate a region can take before CloudFront answers 503 on every page request there. The install reads the quota in all thirteen Lambda@Edge regions and prints it, and the new-distribution template enables a per-address WAF rate limit by default. A breach is a 503, not a silent fallback; the request cap is the one failure this design cannot degrade past.
The router keeps no copy of any mirror. Every diverted request fetches it from the NORG content service, which holds a per-location cache in front of the mirror store behind its own authentication check, so a repeat crawl of the same page is answered from that cache without a storage read. Because every fetch reaches that service, it is also where the visit is recorded: the router passes the user agent, the classification, what it is about to serve and the viewer's network context as one request header, and the service writes the event through its own deferred-work primitive after it has answered. Nothing about analytics runs in your account or waits in front of your visitors.
One event per agent visit, written by the NORG content service, from the details the router passes in one request header on the mirror fetch. On this platform the origin request policy forwards the viewer headers, so these are populated: ip_country, ip_city, asn, http_protocol, tls_version. These two are always null, because CloudFront publishes no equivalent in request headers and inventing a value would be worse than an absent one: as_organization, colo. When no mirror exists yet, the event records the fallback the router declared, stripped or origin; this platform does not distinguish a strip that was discarded for being too thin. Passthrough events for humans are off unless you turn them on with x-norg-events-verbose, and on this platform they are sent best-effort. No query strings, no cookies, and none of the visitor's own request headers beyond the user agent: the geographic and TLS fields above come from headers CloudFront itself adds, not from the visitor. No IP either: the address is read inside the runtime for source verification and never leaves it.
Route exclusions
Most requests to your site should never reach our code. On CloudFront that is enforced by the distribution rather than by us, and the set that matters most on your site is one only you can name.
| Carve-out | Patterns | Why |
|---|---|---|
| Framework assets | /wp-content/*, /wp-includes/* — or /_next/*, /_vercel/* on Next.js | Chosen from the platform detected at install. These are the bulk of a page’s requests and none of them is a mirrorable document. |
| Static suffixes | 49 extensions: images, CSS, JS, fonts, media, archives | Catches assets that live outside the framework prefixes, so a stray /img/logo.png does not invoke the function either. |
| Dynamic routes | /api/*, /wp-json/*, /wp-admin/*, /wp-login.php, /cart, /cart/*, /checkout, /checkout/* | Paths that are transactional or authenticated rather than content. Served with caching disabled and the viewer’s Host corrected, so they behave exactly as they did before the install. |
On this platform a carve-out is a CloudFront cache behaviour with no function association. A request matching one never reaches our code at all, so it is not merely unmodified, it is untouched: nothing is classified, nothing is logged, and no invocation is billed. That is stronger than the in-code fast path other platforms have to rely on, and it is why exclusions are the cost control here as well as the safety one.
CloudFront matches the first behaviour that fits, so order decides outcomes: the carve-outs are created ahead of the default, and a narrower one ahead of a broader one. The install prints the full list before it creates anything, and it refuses rather than overwrite a behaviour you already have on the same path.
The defaults cover the two authenticated paths that are unambiguous on WordPress, /wp-admin/* and /wp-login.php, plus cart and checkout. Everything else behind a login is site-specific and has to be named by you: a customer portal, a member area, a booking history, a patient record, an internal dashboard.
Why we do not guess. Patterns like /account* and /login* were considered as defaults and deliberately left out, because they also match ordinary marketing pages such as /accounting and /login-help. A carve-out that silently un-mirrors a real page is the exact failure the whole design exists to prevent, so the defaults stay narrow and the site-specific ones are yours to add.
What is already safe without any exclusion. Nothing a person submits is ever mediated: only GET requests are considered, so sign-in, checkout and every form POST leave at the first check. The one POST the router handles at all is to an MCP path, and that is relayed to NORG, never to your origin. Content behind a login is never crawled or mirrored either, because NORG only ever sees what an anonymous public crawler sees. So an un-excluded account page is not a disclosure risk.
Why exclude it anyway. Three reasons, in order of how much they will matter to you. It removes those requests from the invocation bill entirely, which matters more now that every page request invokes the router. It keeps a signed-in visitor's page on the shortest possible path through the edge, with no classification at all. And it makes the boundary explicit in your own CloudFront configuration, which is a much easier thing to hand an auditor than an argument about what the code does.
Artifacts, verification and deployment
What you deploy, how to prove it is what we published, and what the platform will not let either of us do.
The two artifacts are plain JavaScript in the public deploy repository, each built from source in the same repository, and each recorded with its full SHA-256 in a committed DIGESTS.json next to them. Hash the files you are about to deploy and compare. The build refuses to publish a bundle that contains a NORG-internal secret name, and refuses one that requires anything the Lambda runtime does not provide.
One deliberate exception to self-containment: the router requires the AWS SDK's Secrets Manager client from the managed Node runtime rather than vendoring it. Bundling it would add tens of thousands of lines to a file whose value is that you can read it. The scheduled heartbeat exercises the same code path and fails loudly, so a runtime that ever stopped providing the SDK would show up in your CloudWatch metrics within the half-hour rather than degrading quietly.
CloudFormation for a new distribution, or a CLI that attaches to one you already have. The CLI dry-runs by default, prints the exact diff, refuses on a conflicting function association, and detaches with one flag. It replaces the default behaviour's cache policy with CachingDisabled and says so in the dry run, because after the install your HTML is no longer cached at the edge; it also prints the Lambda@Edge concurrency quota in every region. It writes no credential into your distribution. Artifacts are pinned by S3 object version, so a rollback is a stack update naming the previous version.
Propagation is five to fifteen minutes for a distribution update, and the kill switch is an origin custom header, so it inherits that timing. Revoking the site key on our side is slower, not faster: the router only learns of it at its next policy refresh, which is up to an hour away by default, and if the secret in your own account is what changed, the fifteen-minute secret cache applies on top. Use the kill switch to stop quickly; use revocation to stop permanently.
Every artifact and log group here is named us-east-1, which looks alarming if your visitors are not in Virginia. It is an administrative detail, not a routing one.
AWS requires the function resource to be created in us-east-1. That is the only thing that region governs. CloudFront then replicates the published version to its regional edge caches and runs it there, so a request from Sydney is classified in Sydney and a request from Frankfurt in Frankfurt, and the site key is read from the Secrets Manager replica in that same region. Nothing is routed via Virginia, and nothing crosses the Pacific to make a decision.
You can confirm this in your own account rather than take our word for it. The log group is named /aws/lambda/us-east-1.<function>, but it is created per region, and the entries appear in the region nearest the edge that served the request. On our own reference install, streams exist in Sydney, Oregon, Dublin and Tokyo; none of that traffic was executed in Virginia.
The practical consequence: classification adds a few milliseconds in the data centre the request had already reached, not a round trip to another continent.
Lambda@Edge supports no environment variables, no layers, no VPC access, no provisioned concurrency and no X-Ray, and functions must be attached as published versions rather than an alias. There is no way to keep work alive after the function returns, which is why the router sends no telemetry: the visit travels as a header on the mirror fetch and the NORG content service records it. Generated responses are capped, and exceeding the cap degrades to your origin. CloudFront resolves the origin's DNS before the function runs, so changing the origin domain inside the function triggers a second resolution.
No verified-bot signal, so source verification is CIDR-only against operator-published ranges. An operator that publishes no ranges is not diverted here, where Cloudflare would fall back to its own signal. That is a capability gap and the direction of the failure is the safe one: it under-serves agents rather than over-serving humans.
The installer credential, and its least-privilege policy
The execution roles read one secret. The credential NORG uses to build and attach the stack needs more, and every action below was discovered by a stack that failed without it. Attach this policy to that user or role; replace <account-id> with your account id.
Everything the install creates belongs to one CloudFormation stack named norg-edge- followed by the first eight hex characters of your site id, and the stack names its functions, roles, schedule rule and alarm under the same prefix. So the credential that runs install_on_cdn can be confined to norg-edge-* for stacks, Lambda functions, IAM roles, EventBridge rules and CloudWatch alarms: it cannot create, change or delete anything in your account that does not carry that name. This is not the execution role's permission described above; it is the credential that builds and attaches the stack, which the Lambda never holds.
Three things cannot be scoped that way, because AWS gives them ids rather than names: the distribution and policy calls in CloudFront, the log groups, and the quota reads. Those take *, and the actions listed for them are the narrowest set the stack uses.
Secrets Manager is the one statement that widens with the adapter version. The install creates the site-key secret and the stack replicates it, so the credential needs the secret's full lifecycle. Until adapter 0.6.1 CloudFormation names that secret SiteKeySecret-<random>, so the resource has to be secret:*, as shown below. From 0.6.1 it is named <stack-name>-site-key and the statement can be tightened to secret:norg-edge-*.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stacks",
"Effect": "Allow",
"Action": [
"cloudformation:CreateStack",
"cloudformation:DeleteStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources",
"cloudformation:ListStacks"
],
"Resource": "arn:aws:cloudformation:us-east-1:<account-id>:stack/norg-edge-*/*"
},
{
"Sid": "StackList",
"Effect": "Allow",
"Action": [
"cloudformation:ListStacks"
],
"Resource": "*"
},
{
"Sid": "Lambda",
"Effect": "Allow",
"Action": [
"lambda:CreateFunction",
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:PublishVersion",
"lambda:DeleteFunction",
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:AddPermission",
"lambda:RemovePermission",
"lambda:EnableReplication",
"lambda:GetPolicy",
"lambda:TagResource",
"lambda:ListVersionsByFunction"
],
"Resource": "arn:aws:lambda:us-east-1:<account-id>:function:norg-edge-*"
},
{
"Sid": "LambdaQuotas",
"Effect": "Allow",
"Action": [
"lambda:GetAccountSettings",
"servicequotas:GetServiceQuota"
],
"Resource": "*"
},
{
"Sid": "Roles",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:GetRole",
"iam:PassRole",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:GetRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole"
],
"Resource": "arn:aws:iam::<account-id>:role/norg-edge-*"
},
{
"Sid": "CloudFront",
"Effect": "Allow",
"Action": [
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
"cloudfront:UpdateDistribution",
"cloudfront:ListDistributions",
"cloudfront:CreateOriginRequestPolicy",
"cloudfront:GetOriginRequestPolicy",
"cloudfront:DeleteOriginRequestPolicy",
"cloudfront:CreateCachePolicy",
"cloudfront:GetCachePolicy",
"cloudfront:DeleteCachePolicy",
"cloudfront:ListCachePolicies",
"cloudfront:ListOriginRequestPolicies"
],
"Resource": "*"
},
{
"Sid": "Secrets",
"Effect": "Allow",
"Action": [
"secretsmanager:CreateSecret",
"secretsmanager:DeleteResourcePolicy",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:GetSecretValue",
"secretsmanager:PutResourcePolicy",
"secretsmanager:PutSecretValue",
"secretsmanager:RemoveRegionsFromReplication",
"secretsmanager:ReplicateSecretToRegions",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:UpdateSecret"
],
"Resource": "arn:aws:secretsmanager:us-east-1:<account-id>:secret:*"
},
{
"Sid": "Schedule",
"Effect": "Allow",
"Action": [
"events:PutRule",
"events:DeleteRule",
"events:DescribeRule",
"events:PutTargets",
"events:RemoveTargets"
],
"Resource": "arn:aws:events:us-east-1:<account-id>:rule/norg-edge-*"
},
{
"Sid": "Alarms",
"Effect": "Allow",
"Action": [
"cloudwatch:PutMetricAlarm",
"cloudwatch:DeleteAlarms",
"cloudwatch:DescribeAlarms"
],
"Resource": "arn:aws:cloudwatch:us-east-1:<account-id>:alarm:norg-edge-*"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:PutRetentionPolicy",
"logs:DescribeLogGroups"
],
"Resource": "*"
},
{
"Sid": "Artifacts",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:GetObjectVersion"
],
"Resource": "arn:aws:s3:::<norg-artifact-bucket>/*"
}
]
}| Action | Why it is there |
|---|---|
| cloudformation:DescribeStackResource | Singular, and needed as well as the plural. The attach step reads the site-key secret's ARN with it, because 0.6.0 publishes no stack output for that ARN. A denial here used to be read as "no secret" and wrote an empty ARN into the distribution; release 250 made it fail the install instead, but the permission is still required. |
| secretsmanager:ReplicateSecretToRegions, RemoveRegionsFromReplication | The stack replicates the secret to the other twelve Lambda@Edge regions on create and withdraws the replicas on delete. |
| secretsmanager:CreateSecret, TagResource, PutSecretValue, DescribeSecret, GetSecretValue, DeleteSecret | The secret's own lifecycle: created by the stack, tagged, given the key, read back once to confirm it, and removed with the stack. |
| lambda:EnableReplication | What Lambda@Edge itself needs: CloudFront copies the published version to its regional caches, and AWS models that as a permission on the function. The other Lambda actions are the create, publish and permission calls the stack makes. |
| cloudfront:Create/Get/Delete CachePolicy and OriginRequestPolicy, plus the two List calls | The stack creates one cache policy and one origin request policy. Their ARNs are ids assigned on creation, so they cannot be scoped to the stack name; nor can the distribution calls. |
| lambda:GetAccountSettings, servicequotas:GetServiceQuota | The install reads the Lambda@Edge concurrency quota in every region and prints it, so a breach is visible before it happens. |
| iam:PassRole | CloudFormation hands the two execution roles to Lambda on the credential's behalf. Scoped to norg-edge-*, it can pass no other role. |
| s3:GetObject, s3:GetObjectVersion | A read on NORG's artifact bucket, not on anything in your account. Artifacts are pinned by object version, which is why the version action is needed too. The bucket name is the stack's ArtifactBucket parameter. |
Write to solutions@norg.ai with your distribution id and the section this is about. We will send the source for the version you are being asked to deploy, and your site's probe token.