@deployfoundation/foundation-deploy 0.2.15 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-image/Dockerfile +1 -0
- package/dist/bin/app.js +13 -3
- package/dist/bin/foundation-deploy.js +121 -7
- package/dist/bin/release-account.js +2 -2
- package/dist/{chunk-7qn00ny1.js → chunk-9rasrppa.js} +46 -4
- package/dist/{chunk-e9vs85ah.js → chunk-pygj3x43.js} +1 -1
- package/dist/{chunk-83781ggk.js → chunk-rq4dc5ns.js} +550 -12
- package/dist/src/index.js +4 -2
- package/package.json +1 -1
- package/src/artifacts.ts +2 -0
- package/src/deploy/cli.ts +3 -0
- package/src/deploy/deploy.ts +49 -0
- package/src/deploy/pages-domain.ts +106 -0
- package/src/deploy/runtime-secret.ts +8 -0
- package/src/deploy/setup.ts +13 -1
- package/src/deploy/slack-manifest.ts +44 -3
- package/src/index.ts +1 -0
- package/src/stacks/agent-stack.ts +78 -0
- package/src/stacks/pages-edge-function.ts +149 -0
- package/src/stacks/pages-stack.ts +504 -0
package/src/deploy/deploy.ts
CHANGED
|
@@ -46,6 +46,13 @@ import {
|
|
|
46
46
|
plannedLicenseCheck,
|
|
47
47
|
verifyLicense,
|
|
48
48
|
} from "./license.ts";
|
|
49
|
+
import {
|
|
50
|
+
type PagesDomainState,
|
|
51
|
+
pagesDomainMode,
|
|
52
|
+
pagesDomainReport,
|
|
53
|
+
pagesDomainState,
|
|
54
|
+
publicNs,
|
|
55
|
+
} from "./pages-domain.ts";
|
|
49
56
|
import { AGENT_DOCKERFILE, FOUNDATION_ROOT, INFRA_ROOT } from "./paths.ts";
|
|
50
57
|
import {
|
|
51
58
|
type ReleaseRequest,
|
|
@@ -239,6 +246,7 @@ export async function cdkDeploy(ctx: AwsContext, opts: CdkDeployOptions): Promis
|
|
|
239
246
|
...(opts.runtimeSecretDigest === undefined
|
|
240
247
|
? []
|
|
241
248
|
: ["-c", `runtimeSecretDigest=${opts.runtimeSecretDigest}`]),
|
|
249
|
+
...(await pagesDomainContext(ctx)),
|
|
242
250
|
];
|
|
243
251
|
await run(
|
|
244
252
|
[
|
|
@@ -259,6 +267,39 @@ export async function cdkDeploy(ctx: AwsContext, opts: CdkDeployOptions): Promis
|
|
|
259
267
|
);
|
|
260
268
|
}
|
|
261
269
|
|
|
270
|
+
/** The custom pages domain's state, read from the pages stack and public DNS. */
|
|
271
|
+
async function pagesDomain(ctx: AwsContext): Promise<PagesDomainState> {
|
|
272
|
+
return pagesDomainState(ctx.instance, {
|
|
273
|
+
output: (key) => optionalOutput(ctx, ctx.names.pages, key),
|
|
274
|
+
resolveNs: publicNs,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* `-c pagesDomainDelegated=true` once a Foundation-made pages zone is
|
|
280
|
+
* delegated, so this deploy issues the certificate and attaches the domain
|
|
281
|
+
* (`pages-domain.ts`). A dry run looks nothing up and leaves it pending.
|
|
282
|
+
*/
|
|
283
|
+
async function pagesDomainContext(ctx: AwsContext): Promise<string[]> {
|
|
284
|
+
if (pagesDomainMode(ctx.instance) !== "own-zone" || ctx.dryRun === true) return [];
|
|
285
|
+
return (await pagesDomain(ctx)).delegated ? ["-c", "pagesDomainDelegated=true"] : [];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Where pages are served, and the NS checklist step while a custom domain waits. */
|
|
289
|
+
export async function reportPagesDomain(ctx: AwsContext): Promise<void> {
|
|
290
|
+
if (!ctx.instance.integrations.pages) return;
|
|
291
|
+
console.log("▶ hosted pages");
|
|
292
|
+
if (ctx.dryRun === true) {
|
|
293
|
+
console.log(
|
|
294
|
+
` <${ctx.names.pages}.PagesBaseUrl>; NS records from <${ctx.names.pages}.PagesNameServers>`,
|
|
295
|
+
);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const state = await pagesDomain(ctx);
|
|
299
|
+
const baseUrl = await optionalOutput(ctx, ctx.names.pages, "PagesBaseUrl");
|
|
300
|
+
for (const line of pagesDomainReport(state, baseUrl)) console.log(line);
|
|
301
|
+
}
|
|
302
|
+
|
|
262
303
|
/** A stack output that may not exist yet (a stack deployed before it was added). */
|
|
263
304
|
async function optionalOutput(
|
|
264
305
|
ctx: AwsContext,
|
|
@@ -370,6 +411,12 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
|
|
|
370
411
|
? `<${api}.CrmPolicyFingerprint>`
|
|
371
412
|
: await optionalOutput(ctx, api, "CrmPolicyFingerprint")
|
|
372
413
|
: undefined;
|
|
414
|
+
// Pages have a stack of their own, present only with the integration.
|
|
415
|
+
const pagesApiFunctionArn = ctx.instance.integrations.pages
|
|
416
|
+
? dry
|
|
417
|
+
? `<${ctx.names.pages}.PagesApiFunctionArn>`
|
|
418
|
+
: await optionalOutput(ctx, ctx.names.pages, "PagesApiFunctionArn")
|
|
419
|
+
: undefined;
|
|
373
420
|
const otterProxyFunctionArn = dry
|
|
374
421
|
? `<${api}.OtterProxyFunctionArn>`
|
|
375
422
|
: await optionalOutput(ctx, api, "OtterProxyFunctionArn");
|
|
@@ -414,6 +461,7 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
|
|
|
414
461
|
...(browserProxyFunctionArn === undefined ? {} : { browserProxyFunctionArn }),
|
|
415
462
|
...(crmProxyFunctionArn === undefined ? {} : { crmProxyFunctionArn }),
|
|
416
463
|
...(crmPolicyFingerprint === undefined ? {} : { crmPolicyFingerprint }),
|
|
464
|
+
...(pagesApiFunctionArn === undefined ? {} : { pagesApiFunctionArn }),
|
|
417
465
|
...(otterProxyFunctionArn === undefined ? {} : { otterProxyFunctionArn }),
|
|
418
466
|
...(knockProxyFunctionArn === undefined ? {} : { knockProxyFunctionArn }),
|
|
419
467
|
...(upworkProxyFunctionArn === undefined ? {} : { upworkProxyFunctionArn }),
|
|
@@ -630,6 +678,7 @@ export async function deploy(ctx: AwsContext, opts: DeployOptions): Promise<void
|
|
|
630
678
|
...(runtimeSecretDigest === undefined ? {} : { runtimeSecretDigest }),
|
|
631
679
|
});
|
|
632
680
|
await deployStacks(deployed);
|
|
681
|
+
await reportPagesDomain(ctx);
|
|
633
682
|
if (ctx.instance.integrations.knock) {
|
|
634
683
|
console.log(`▶ Knock OAuth public-client registration (${ctx.names.secretKnockOauthClient})`);
|
|
635
684
|
console.log(` ${await syncKnockOAuthClient(ctx)}`);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The custom pages domain's delegation, as the deploy tool sees it.
|
|
3
|
+
*
|
|
4
|
+
* With `pages.domain` and no `pages.hostedZoneId`, the pages stack creates a
|
|
5
|
+
* Route 53 zone for exactly that subdomain and outputs its name servers. The
|
|
6
|
+
* company adds those as NS records at its own DNS provider. Until they
|
|
7
|
+
* resolve, issuing the certificate would leave CloudFormation waiting on ACM
|
|
8
|
+
* validation, so the stack keeps serving on the CloudFront default domain.
|
|
9
|
+
* Before each `cdk deploy` this module looks the delegation up in public DNS
|
|
10
|
+
* and, once it matches the zone, passes `-c pagesDomainDelegated=true`: that
|
|
11
|
+
* deploy issues the certificate and attaches the domain.
|
|
12
|
+
*
|
|
13
|
+
* Sticky: once the stack reports this domain attached, it stays attached
|
|
14
|
+
* without another lookup, so a transient DNS failure can never detach a
|
|
15
|
+
* working domain. A changed `pages.domain` starts over.
|
|
16
|
+
*/
|
|
17
|
+
import { Resolver } from "node:dns/promises";
|
|
18
|
+
|
|
19
|
+
export type PagesDomainMode = "none" | "own-zone" | "parent-zone";
|
|
20
|
+
|
|
21
|
+
export interface PagesDomainInstance {
|
|
22
|
+
integrations: { pages: boolean };
|
|
23
|
+
pages: { domain?: string; hostedZoneId?: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PagesDomainDeps {
|
|
27
|
+
/** A pages stack output, or undefined when the stack or output does not exist yet. */
|
|
28
|
+
output(key: string): Promise<string | undefined>;
|
|
29
|
+
/** NS records for a name from public DNS; empty when there are none. */
|
|
30
|
+
resolveNs(name: string): Promise<string[]>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PagesDomainState {
|
|
34
|
+
mode: PagesDomainMode;
|
|
35
|
+
domain?: string;
|
|
36
|
+
/** The certificate and alias may be (or already are) attached. */
|
|
37
|
+
delegated: boolean;
|
|
38
|
+
/** The Foundation-made zone's name servers, for the NS checklist step. */
|
|
39
|
+
nameServers: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pagesDomainMode(instance: PagesDomainInstance): PagesDomainMode {
|
|
43
|
+
if (!instance.integrations.pages || instance.pages.domain === undefined) return "none";
|
|
44
|
+
return instance.pages.hostedZoneId === undefined ? "own-zone" : "parent-zone";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normal(name: string): string {
|
|
48
|
+
return name.trim().toLowerCase().replace(/\.$/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function pagesDomainState(
|
|
52
|
+
instance: PagesDomainInstance,
|
|
53
|
+
deps: PagesDomainDeps,
|
|
54
|
+
): Promise<PagesDomainState> {
|
|
55
|
+
const mode = pagesDomainMode(instance);
|
|
56
|
+
const domain = instance.pages.domain;
|
|
57
|
+
if (mode === "none" || domain === undefined) return { mode, delegated: false, nameServers: [] };
|
|
58
|
+
// The company's own zone is authoritative already: nothing to wait for.
|
|
59
|
+
if (mode === "parent-zone") return { mode, domain, delegated: true, nameServers: [] };
|
|
60
|
+
const nameServers = ((await deps.output("PagesNameServers")) ?? "")
|
|
61
|
+
.split(",")
|
|
62
|
+
.map(normal)
|
|
63
|
+
.filter((server) => server !== "");
|
|
64
|
+
if (
|
|
65
|
+
(await deps.output("PagesCustomDomainAttached")) === "true" &&
|
|
66
|
+
(await deps.output("PagesCustomDomain")) === domain
|
|
67
|
+
)
|
|
68
|
+
return { mode, domain, delegated: true, nameServers };
|
|
69
|
+
if (nameServers.length === 0) return { mode, domain, delegated: false, nameServers };
|
|
70
|
+
let answer: string[];
|
|
71
|
+
try {
|
|
72
|
+
answer = (await deps.resolveNs(domain)).map(normal);
|
|
73
|
+
} catch {
|
|
74
|
+
answer = [];
|
|
75
|
+
}
|
|
76
|
+
const delegated = nameServers.every((server) => answer.includes(server));
|
|
77
|
+
return { mode, domain, delegated, nameServers };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Public DNS, not the machine's resolver: a split-horizon office network must not decide. */
|
|
81
|
+
export async function publicNs(name: string): Promise<string[]> {
|
|
82
|
+
const resolver = new Resolver({ timeout: 5_000, tries: 2 });
|
|
83
|
+
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
84
|
+
try {
|
|
85
|
+
return await resolver.resolveNs(name);
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The lines the deploy prints: a checklist step while pending, the address once attached. */
|
|
92
|
+
export function pagesDomainReport(state: PagesDomainState, baseUrl: string | undefined): string[] {
|
|
93
|
+
if (state.mode === "none" || state.domain === undefined)
|
|
94
|
+
return baseUrl === undefined ? [] : [` pages are served at ${baseUrl}`];
|
|
95
|
+
if (state.delegated) return [` pages are served at https://${state.domain}`];
|
|
96
|
+
if (state.nameServers.length === 0)
|
|
97
|
+
return [
|
|
98
|
+
` ${state.domain}: the pages zone is created by this deploy; its NS records follow on the next run.`,
|
|
99
|
+
];
|
|
100
|
+
return [
|
|
101
|
+
` ☐ add these NS records for ${state.domain} at your DNS provider:`,
|
|
102
|
+
...state.nameServers.map((server) => ` ${state.domain}. NS ${server}.`),
|
|
103
|
+
` until they resolve, pages are served at ${baseUrl ?? "the CloudFront default domain"}.`,
|
|
104
|
+
" Deploy again once they do: that deploy issues the certificate and moves pages to the domain.",
|
|
105
|
+
];
|
|
106
|
+
}
|
|
@@ -76,6 +76,11 @@ export interface RuntimeSecretInputs {
|
|
|
76
76
|
crmProxyFunctionArn?: string;
|
|
77
77
|
/** Hash of the CRM policy baked into the deployed proxy; never a credential. */
|
|
78
78
|
crmPolicyFingerprint?: string;
|
|
79
|
+
/**
|
|
80
|
+
* `FoundationPages.PagesApiFunctionArn` — the only pages binding the
|
|
81
|
+
* container gets. Bucket, table and cookie key stay behind the proxy.
|
|
82
|
+
*/
|
|
83
|
+
pagesApiFunctionArn?: string;
|
|
79
84
|
/**
|
|
80
85
|
* `FoundationApi.OtterProxyFunctionArn` — the ONLY Otter binding given to the
|
|
81
86
|
* shell-capable container. The API credential remains readable solely by
|
|
@@ -217,6 +222,9 @@ export function composeRuntimeSecret(inputs: RuntimeSecretInputs): Record<string
|
|
|
217
222
|
...(inputs.crmPolicyFingerprint !== undefined && inputs.crmPolicyFingerprint !== ""
|
|
218
223
|
? { FOUNDATION_CRM_POLICY_FINGERPRINT: inputs.crmPolicyFingerprint }
|
|
219
224
|
: {}),
|
|
225
|
+
...(inputs.pagesApiFunctionArn !== undefined && inputs.pagesApiFunctionArn !== ""
|
|
226
|
+
? { FOUNDATION_PAGES_API_FUNCTION_ARN: inputs.pagesApiFunctionArn }
|
|
227
|
+
: {}),
|
|
220
228
|
...(inputs.otterProxyFunctionArn !== undefined && inputs.otterProxyFunctionArn !== ""
|
|
221
229
|
? { FOUNDATION_OTTER_PROXY_FUNCTION_ARN: inputs.otterProxyFunctionArn }
|
|
222
230
|
: {}),
|
package/src/deploy/setup.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
cdkDeploy,
|
|
34
34
|
currentRuntimeSecretDigest,
|
|
35
35
|
refreshRuntimeSecret,
|
|
36
|
+
reportPagesDomain,
|
|
36
37
|
syncKnockOAuthClient,
|
|
37
38
|
syncRuntimeSecret,
|
|
38
39
|
} from "./deploy.ts";
|
|
@@ -189,6 +190,16 @@ async function reportSlackManifest(ctx: AwsContext): Promise<void> {
|
|
|
189
190
|
EVENTS_REQUEST_URL: await output(ctx, ctx.names.api, "EventsUrl"),
|
|
190
191
|
COMMANDS_REQUEST_URL: await output(ctx, ctx.names.api, "CommandsUrl"),
|
|
191
192
|
INTERACTIVE_REQUEST_URL: await output(ctx, ctx.names.api, "InteractiveUrl"),
|
|
193
|
+
// Both sign-in callbacks: the pages domain's, and the CloudFront default
|
|
194
|
+
// name's, which links posted before a custom domain attached still use.
|
|
195
|
+
...(ctx.instance.integrations.pages
|
|
196
|
+
? {
|
|
197
|
+
PAGES_AUTH_REDIRECT_URL: [
|
|
198
|
+
await output(ctx, ctx.names.pages, "PagesAuthRedirectUrl"),
|
|
199
|
+
await output(ctx, ctx.names.pages, "PagesDefaultAuthRedirectUrl"),
|
|
200
|
+
].join(","),
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
192
203
|
});
|
|
193
204
|
console.log(` manifest: ${JSON.stringify(manifest)}`);
|
|
194
205
|
console.log(
|
|
@@ -411,8 +422,9 @@ export async function setup(
|
|
|
411
422
|
step(13, "deploy the instance's own pipeline, if it has one");
|
|
412
423
|
await deployPipelineStack(ctx);
|
|
413
424
|
|
|
414
|
-
step(14, "the Slack app manifest for the deployed API");
|
|
425
|
+
step(14, "the Slack app manifest for the deployed API, and the pages domain");
|
|
415
426
|
await reportSlackManifest(ctx);
|
|
427
|
+
await reportPagesDomain(ctx);
|
|
416
428
|
|
|
417
429
|
step(15, "smoke test the runtime");
|
|
418
430
|
await smokeTest(ctx);
|
|
@@ -33,17 +33,27 @@ export interface SlackManifestInstance {
|
|
|
33
33
|
displayName: string;
|
|
34
34
|
/** Rendered into `/<prefix>-<suffix>` for every slash command. */
|
|
35
35
|
commandPrefix: string;
|
|
36
|
+
/**
|
|
37
|
+
* The instance hosts pages: the app then asks for the `openid` user scope
|
|
38
|
+
* that Sign in with Slack needs, and registers the pages sign-in redirect.
|
|
39
|
+
*/
|
|
40
|
+
pages?: boolean;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export interface SlackManifestEnv {
|
|
39
44
|
EVENTS_REQUEST_URL?: string;
|
|
40
45
|
COMMANDS_REQUEST_URL?: string;
|
|
41
46
|
INTERACTIVE_REQUEST_URL?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Comma-separated sign-in callbacks: the pages stack's `PagesAuthRedirectUrl`
|
|
49
|
+
* and `PagesDefaultAuthRedirectUrl` outputs.
|
|
50
|
+
*/
|
|
51
|
+
PAGES_AUTH_REDIRECT_URL?: string;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
export interface SlackManifest {
|
|
45
55
|
features: { slash_commands?: Array<{ command: string }> };
|
|
46
|
-
oauth_config: { scopes: { bot: string[] } };
|
|
56
|
+
oauth_config: { scopes: { bot: string[]; user?: string[] }; redirect_urls?: string[] };
|
|
47
57
|
settings: {
|
|
48
58
|
event_subscriptions?: { request_url: string; bot_events: string[] };
|
|
49
59
|
interactivity?: unknown;
|
|
@@ -75,6 +85,21 @@ export function buildSlackManifest(
|
|
|
75
85
|
"app-manifest.yml: bot scopes are generated from the Foundation capability registry; remove them from the template",
|
|
76
86
|
);
|
|
77
87
|
manifest.oauth_config = { scopes: { bot: requiredSlackScopes(CAPABILITY_IDS) } };
|
|
88
|
+
if (instance.pages === true) {
|
|
89
|
+
// Sign in with Slack (OpenID Connect) for hosted pages: a user scope and
|
|
90
|
+
// the sign-in callback. Without the URL (the pages stack is not deployed
|
|
91
|
+
// yet) the scope alone is harmless and the redirect is added next time.
|
|
92
|
+
manifest.oauth_config.scopes.user = ["openid"];
|
|
93
|
+
const redirects = [
|
|
94
|
+
...new Set(
|
|
95
|
+
(env.PAGES_AUTH_REDIRECT_URL ?? "")
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((url) => url.trim())
|
|
98
|
+
.filter((url) => url !== ""),
|
|
99
|
+
),
|
|
100
|
+
];
|
|
101
|
+
if (redirects.length > 0) manifest.oauth_config.redirect_urls = redirects;
|
|
102
|
+
}
|
|
78
103
|
|
|
79
104
|
// Every command a registered capability needs must be in the template; the
|
|
80
105
|
// template may carry more (help, drive) than the registry knows about.
|
|
@@ -106,7 +131,23 @@ export function buildSlackManifest(
|
|
|
106
131
|
export function slackManifestFor(instance: Instance, env: SlackManifestEnv): SlackManifest {
|
|
107
132
|
return buildSlackManifest(
|
|
108
133
|
readFileSync(SLACK_APP_MANIFEST_PATH, "utf8"),
|
|
109
|
-
{
|
|
110
|
-
|
|
134
|
+
{
|
|
135
|
+
displayName: instance.displayName,
|
|
136
|
+
commandPrefix: slackCommandPrefix(instance),
|
|
137
|
+
pages: instance.integrations.pages,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
...env,
|
|
141
|
+
// A custom pages domain's callback is known from the instance file alone;
|
|
142
|
+
// registering it early is harmless while the domain waits for delegation.
|
|
143
|
+
...(instance.integrations.pages && instance.pages.domain !== undefined
|
|
144
|
+
? {
|
|
145
|
+
PAGES_AUTH_REDIRECT_URL: [
|
|
146
|
+
`https://${instance.pages.domain}/_auth/callback`,
|
|
147
|
+
...(env.PAGES_AUTH_REDIRECT_URL === undefined ? [] : [env.PAGES_AUTH_REDIRECT_URL]),
|
|
148
|
+
].join(","),
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
151
|
+
},
|
|
111
152
|
);
|
|
112
153
|
}
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,7 @@ export { FoundationCi, type FoundationCiProps } from "./stacks/ci-stack.ts";
|
|
|
52
52
|
export { FoundationData, type FoundationDataProps } from "./stacks/data-stack.ts";
|
|
53
53
|
export { FoundationNetwork, type FoundationNetworkProps } from "./stacks/network-stack.ts";
|
|
54
54
|
export { NewsletterStack, type NewsletterStackProps } from "./stacks/newsletter-stack.ts";
|
|
55
|
+
export { FoundationPages, type FoundationPagesProps } from "./stacks/pages-stack.ts";
|
|
55
56
|
export {
|
|
56
57
|
BUILD_TIMEOUT,
|
|
57
58
|
DEFAULT_BUILDSPEC_PATH,
|
|
@@ -401,6 +401,82 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
401
401
|
);
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Hosted pages belong to the conversations that made them, and every read
|
|
406
|
+
* or write goes through the Pages API, which checks the conversation. The
|
|
407
|
+
* shell-capable agent (and the ReadOnlyAccess role it can assume, which
|
|
408
|
+
* would otherwise read any bucket) must not reach the bucket, table, cookie
|
|
409
|
+
* key or edge key store directly: that would let one channel's turn read
|
|
410
|
+
* another channel's pages, or mint a viewing cookie. Matched by name
|
|
411
|
+
* pattern, like the CRM table, so retained resources stay covered and
|
|
412
|
+
* FoundationAgent needs nothing from FoundationPages.
|
|
413
|
+
*
|
|
414
|
+
* The execution role also gets its one pages grant here: invoking the Pages
|
|
415
|
+
* API, by function name. Both go in a managed policy of their own so the
|
|
416
|
+
* roles' inline policies (the execution role's is near IAM's size limit)
|
|
417
|
+
* are unchanged for instances without pages.
|
|
418
|
+
*/
|
|
419
|
+
private pagesPolicy(
|
|
420
|
+
id: string,
|
|
421
|
+
role: iam.Role,
|
|
422
|
+
instance: Instance,
|
|
423
|
+
options: { invoke: boolean },
|
|
424
|
+
): void {
|
|
425
|
+
if (!provisionsIntegration(instance, "pages")) return;
|
|
426
|
+
const stack = cdk.Stack.of(this);
|
|
427
|
+
const table = stack.formatArn({
|
|
428
|
+
service: "dynamodb",
|
|
429
|
+
resource: "table",
|
|
430
|
+
resourceName: "*-PagesTable*",
|
|
431
|
+
});
|
|
432
|
+
const statements = [
|
|
433
|
+
new iam.PolicyStatement({
|
|
434
|
+
sid: "DenyDirectPagesAccess",
|
|
435
|
+
effect: iam.Effect.DENY,
|
|
436
|
+
actions: [
|
|
437
|
+
"s3:*",
|
|
438
|
+
"dynamodb:*",
|
|
439
|
+
"secretsmanager:GetSecretValue",
|
|
440
|
+
"secretsmanager:BatchGetSecretValue",
|
|
441
|
+
"cloudfront-keyvaluestore:*",
|
|
442
|
+
],
|
|
443
|
+
resources: [
|
|
444
|
+
`arn:${stack.partition}:s3:::*pagesbucket*`,
|
|
445
|
+
`arn:${stack.partition}:s3:::*pagesbucket*/*`,
|
|
446
|
+
table,
|
|
447
|
+
`${table}/index/*`,
|
|
448
|
+
stack.formatArn({
|
|
449
|
+
service: "secretsmanager",
|
|
450
|
+
resource: "secret",
|
|
451
|
+
resourceName: `${this.names.secretPagesCookieKey}-??????`,
|
|
452
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
453
|
+
}),
|
|
454
|
+
`arn:${stack.partition}:cloudfront::${stack.account}:key-value-store/*`,
|
|
455
|
+
],
|
|
456
|
+
}),
|
|
457
|
+
];
|
|
458
|
+
if (options.invoke)
|
|
459
|
+
statements.push(
|
|
460
|
+
new iam.PolicyStatement({
|
|
461
|
+
sid: "PagesApiInvoke",
|
|
462
|
+
actions: ["lambda:InvokeFunction"],
|
|
463
|
+
resources: [
|
|
464
|
+
stack.formatArn({
|
|
465
|
+
service: "lambda",
|
|
466
|
+
resource: "function",
|
|
467
|
+
resourceName: this.names.pagesApiFunctionName,
|
|
468
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
469
|
+
}),
|
|
470
|
+
],
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
new iam.ManagedPolicy(this, id, {
|
|
474
|
+
description: `${instance.displayName} hosted pages: only through the Pages API`,
|
|
475
|
+
roles: [role],
|
|
476
|
+
statements,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
404
480
|
private denyDirectUpworkApprovalAccess(
|
|
405
481
|
role: iam.Role,
|
|
406
482
|
upworkApprovalTable: dynamodb.ITable | undefined,
|
|
@@ -484,6 +560,7 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
484
560
|
this.denyDirectKnockSecretAccess(role, props);
|
|
485
561
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
486
562
|
this.denyDirectEtradeAccess(role, props);
|
|
563
|
+
this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
|
|
487
564
|
role.addToPolicy(
|
|
488
565
|
new iam.PolicyStatement({
|
|
489
566
|
sid: "DenyAgentCoreBrowserMetadata",
|
|
@@ -576,6 +653,7 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
576
653
|
this.denyDirectKnockSecretAccess(role, props);
|
|
577
654
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
578
655
|
this.denyDirectEtradeAccess(role, props);
|
|
656
|
+
this.pagesPolicy("AgentExecutionPagesPolicy", role, props.instance, { invoke: true });
|
|
579
657
|
|
|
580
658
|
// Foundation's own repository when a release names the image, because the
|
|
581
659
|
// runtime then pulls across accounts. The pull also needs Foundation's
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CloudFront viewer-request function in front of every hosted page.
|
|
3
|
+
*
|
|
4
|
+
* It runs on each request, before the cache, in CloudFront's `cloudfront-js-2.0`
|
|
5
|
+
* runtime, and does three things:
|
|
6
|
+
*
|
|
7
|
+
* 1. `/_auth/*` passes through to the sign-in Lambda, with the viewer's Host
|
|
8
|
+
* copied into `x-foundation-pages-host` (the Lambda's Function URL rejects
|
|
9
|
+
* a foreign Host, and needs the real one to build Slack's redirect URI).
|
|
10
|
+
* 2. `/p/<pageId>/…` needs a valid `fpa` cookie for that page: the HMAC the
|
|
11
|
+
* sign-in Lambda made with the key this function reads from the attached
|
|
12
|
+
* KeyValueStore (`@deployfoundation/foundation-core/page-cookie` is the
|
|
13
|
+
* signer; a test holds the two to one format). No cookie, a cookie for
|
|
14
|
+
* another page, or an expired one is a 302 to `/_auth/start`.
|
|
15
|
+
* 3. A granted request is rewritten onto its S3 key (the layout in
|
|
16
|
+
* `packages/capability-pages/src/layout.ts`): a trailing `/` gets
|
|
17
|
+
* `index.html`, and an extensionless path is a single-page-app route that
|
|
18
|
+
* gets the version's root `index.html` (how an open-slide deck's `/s/<id>`
|
|
19
|
+
* route loads). `source/` and `uploads/` have no public path at all.
|
|
20
|
+
*
|
|
21
|
+
* Anything else is a 404. Dot segments and encoded separators are refused
|
|
22
|
+
* before rewriting so no path can climb into another page's prefix.
|
|
23
|
+
*
|
|
24
|
+
* The runtime is not Node: `var`, plain functions, and only the `crypto`
|
|
25
|
+
* HMAC/hash subset. `__KVS_ID__` is replaced with the store id at synth.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The KeyValueStore key holding the cookie HMAC key; the seeding resource writes it. */
|
|
29
|
+
export const PAGES_EDGE_KEY_NAME = "cookie-key-v1";
|
|
30
|
+
/** Header the function sets for the sign-in Lambda. */
|
|
31
|
+
export const PAGES_HOST_HEADER = "x-foundation-pages-host";
|
|
32
|
+
|
|
33
|
+
export const PAGES_VIEWER_REQUEST_SOURCE = `import cf from 'cloudfront';
|
|
34
|
+
import crypto from 'crypto';
|
|
35
|
+
|
|
36
|
+
var kvs = cf.kvs('__KVS_ID__');
|
|
37
|
+
var COOKIE = 'fpa';
|
|
38
|
+
var KEY_NAME = '${PAGES_EDGE_KEY_NAME}';
|
|
39
|
+
var HOST_HEADER = '${PAGES_HOST_HEADER}';
|
|
40
|
+
var PAGE_RE = /^\\/p\\/([a-z0-9]{24})(\\/.*)?$/;
|
|
41
|
+
var VERSION_RE = /^\\/v([1-9][0-9]{0,5})(\\/.*)?$/;
|
|
42
|
+
var EXPORT_RE = /^\\/exports\\/v([1-9][0-9]{0,5})\\/([^\\/]+)$/;
|
|
43
|
+
var UNSAFE_RE = /(^|\\/)\\.\\.?(\\/|$)|\\/\\/|%2e|%2f|%5c|\\\\/i;
|
|
44
|
+
var USER_RE = /^[UW][A-Z0-9]{2,31}$/;
|
|
45
|
+
var EXP_RE = /^[0-9]{1,12}$/;
|
|
46
|
+
|
|
47
|
+
function reply(status, description, body) {
|
|
48
|
+
return {
|
|
49
|
+
statusCode: status,
|
|
50
|
+
statusDescription: description,
|
|
51
|
+
headers: {
|
|
52
|
+
'content-type': { value: 'text/plain; charset=utf-8' },
|
|
53
|
+
'cache-control': { value: 'no-store' }
|
|
54
|
+
},
|
|
55
|
+
body: { encoding: 'text', data: body }
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function redirect(location) {
|
|
60
|
+
return {
|
|
61
|
+
statusCode: 302,
|
|
62
|
+
statusDescription: 'Found',
|
|
63
|
+
headers: { location: { value: location }, 'cache-control': { value: 'no-store' } }
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function equal(a, b) {
|
|
68
|
+
if (a.length !== b.length) return false;
|
|
69
|
+
var diff = 0;
|
|
70
|
+
for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
71
|
+
return diff === 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validCookie(value, pageId, key, now) {
|
|
75
|
+
if (typeof value !== 'string' || value.length > 256) return false;
|
|
76
|
+
var parts = value.split('.');
|
|
77
|
+
if (parts.length !== 5 || parts[0] !== 'v1' || parts[1] !== pageId) return false;
|
|
78
|
+
if (!USER_RE.test(parts[2]) || !EXP_RE.test(parts[3]) || Number(parts[3]) <= now) return false;
|
|
79
|
+
var mac = crypto.createHmac('sha256', key).update(parts.slice(0, 4).join('.')).digest('hex');
|
|
80
|
+
return equal(mac, parts[4]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cookieValues(request) {
|
|
84
|
+
var cookie = request.cookies ? request.cookies[COOKIE] : undefined;
|
|
85
|
+
if (!cookie) return [];
|
|
86
|
+
var values = [cookie.value];
|
|
87
|
+
if (cookie.multiValue) {
|
|
88
|
+
for (var i = 0; i < cookie.multiValue.length; i++) values.push(cookie.multiValue[i].value);
|
|
89
|
+
}
|
|
90
|
+
return values;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function objectPath(pageId, rest) {
|
|
94
|
+
var prefix = '/pages/' + pageId;
|
|
95
|
+
if (rest === '/') return prefix + '/latest.html';
|
|
96
|
+
var version = VERSION_RE.exec(rest);
|
|
97
|
+
if (version) {
|
|
98
|
+
var sub = version[2];
|
|
99
|
+
if (sub.charAt(sub.length - 1) === '/') {
|
|
100
|
+
sub = sub + 'index.html';
|
|
101
|
+
} else if (sub.slice(sub.lastIndexOf('/') + 1).indexOf('.') === -1) {
|
|
102
|
+
sub = '/index.html';
|
|
103
|
+
}
|
|
104
|
+
return prefix + '/v' + version[1] + '/build' + sub;
|
|
105
|
+
}
|
|
106
|
+
var download = EXPORT_RE.exec(rest);
|
|
107
|
+
if (download) return prefix + '/v' + download[1] + '/exports/' + download[2];
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handler(event) {
|
|
112
|
+
var request = event.request;
|
|
113
|
+
var uri = request.uri;
|
|
114
|
+
if (uri.indexOf('/_auth/') === 0) {
|
|
115
|
+
request.headers[HOST_HEADER] = { value: request.headers.host ? request.headers.host.value : '' };
|
|
116
|
+
return request;
|
|
117
|
+
}
|
|
118
|
+
var page = PAGE_RE.exec(uri);
|
|
119
|
+
if (!page || UNSAFE_RE.test(uri)) return reply(404, 'Not Found', 'Not found');
|
|
120
|
+
var pageId = page[1];
|
|
121
|
+
var rest = page[2] || '';
|
|
122
|
+
if (rest === '') return redirect(uri + '/');
|
|
123
|
+
var version = VERSION_RE.exec(rest);
|
|
124
|
+
if (version && version[2] === undefined) return redirect(uri + '/');
|
|
125
|
+
var target = objectPath(pageId, rest);
|
|
126
|
+
if (target === null) return reply(404, 'Not Found', 'Not found');
|
|
127
|
+
|
|
128
|
+
var key;
|
|
129
|
+
try {
|
|
130
|
+
key = await kvs.get(KEY_NAME);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return reply(503, 'Service Unavailable', 'Pages sign-in is starting up. Try again in a minute.');
|
|
133
|
+
}
|
|
134
|
+
var now = Math.floor(Date.now() / 1000);
|
|
135
|
+
var values = cookieValues(request);
|
|
136
|
+
for (var i = 0; i < values.length; i++) {
|
|
137
|
+
if (validCookie(values[i], pageId, key, now)) {
|
|
138
|
+
request.uri = target;
|
|
139
|
+
return request;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return redirect('/_auth/start?page=' + pageId + '&next=' + encodeURIComponent(uri));
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
/** The function source for one store; `kvsId` may be a CDK token. */
|
|
147
|
+
export function pagesViewerRequestCode(kvsId: string): string {
|
|
148
|
+
return PAGES_VIEWER_REQUEST_SOURCE.replace("__KVS_ID__", kvsId);
|
|
149
|
+
}
|