@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.40
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/amplify/data/resource.js +22 -3
- package/amplify/data/resource.ts +24 -3
- package/amplify/data/schema.js +2029 -1967
- package/amplify/data/schema.ts +72 -2
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/content-actions/handler.py +33 -0
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/functions/editorial-checks/schema.ts +123 -0
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +36 -1
- package/amplify/site-backend.ts +44 -1
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +362 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-article-editor.tsx +136 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +74 -61
- package/components/newsroom-references-view.tsx +14 -14
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +27 -2
- package/components/use-editorial-checks.ts +133 -0
- package/infra/amplify-app-shell.js +76 -16
- package/infra/amplify-app-shell.ts +86 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +75 -6
- package/infra/site-config.ts +106 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +212 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +171 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/prospectus-session.ts +14 -14
- package/lib/prospectus-sign-in-routes.ts +52 -11
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +23 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +48 -4
- package/routes.manifest.json +34 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
|
|
2
2
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
4
|
-
import
|
|
4
|
+
import { SecureStringParameter } from "./secure-string-parameter.js";
|
|
5
5
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
6
6
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
7
|
-
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
7
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
8
8
|
function environmentVariables(variables) {
|
|
9
9
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
10
10
|
}
|
|
@@ -68,6 +68,9 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
68
68
|
if (!config.reader)
|
|
69
69
|
throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
|
|
70
70
|
this.readerAppId = this.addReaderApp(config, config.reader);
|
|
71
|
+
if (config.autoDeploy) {
|
|
72
|
+
this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
|
|
73
|
+
}
|
|
71
74
|
return;
|
|
72
75
|
}
|
|
73
76
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
@@ -89,33 +92,35 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
89
92
|
});
|
|
90
93
|
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
91
94
|
if (revalidateSecretParameterName) {
|
|
95
|
+
const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
|
|
96
|
+
parameterName: revalidateSecretParameterName,
|
|
97
|
+
description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
|
|
98
|
+
});
|
|
92
99
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
93
100
|
sid: "ReadRevalidateSecret",
|
|
94
101
|
actions: ["ssm:GetParameter"],
|
|
95
|
-
resources: [
|
|
96
|
-
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
97
|
-
],
|
|
102
|
+
resources: [revalidateSecret.parameterArn],
|
|
98
103
|
}));
|
|
99
104
|
}
|
|
100
105
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
101
|
-
const
|
|
102
|
-
? new
|
|
106
|
+
const prospectusSessionSealing = prospectusAccountsApiStageArn
|
|
107
|
+
? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
|
|
108
|
+
parameterName: resolveProspectusSessionSealingParameterName(config),
|
|
103
109
|
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
104
|
-
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
105
110
|
})
|
|
106
111
|
: undefined;
|
|
107
|
-
const prospectusEnvironment = prospectusAccountsApiStageArn &&
|
|
112
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
|
|
108
113
|
? {
|
|
109
114
|
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
110
|
-
|
|
115
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
|
|
111
116
|
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
112
117
|
}
|
|
113
118
|
: {};
|
|
114
|
-
if (
|
|
119
|
+
if (prospectusSessionSealing) {
|
|
115
120
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
116
121
|
sid: "ReadProspectusSessionSealingSecret",
|
|
117
|
-
actions: ["
|
|
118
|
-
resources: [
|
|
122
|
+
actions: ["ssm:GetParameter"],
|
|
123
|
+
resources: [prospectusSessionSealing.parameterArn],
|
|
119
124
|
}));
|
|
120
125
|
}
|
|
121
126
|
if (prospectusAccountsApiStageArn) {
|
|
@@ -153,9 +158,15 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
153
158
|
const stagingOrigin = stagingDomainName
|
|
154
159
|
? `https://${stagingDomainName}/`
|
|
155
160
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
|
|
161
|
+
// The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
|
|
162
|
+
// A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
|
|
163
|
+
const siteOrigin = resolveSiteOrigin(config);
|
|
164
|
+
const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
|
|
165
|
+
const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
|
|
156
166
|
const sharedRootDomain = config.cms.domainPrefix !== undefined;
|
|
157
167
|
const defaultOriginsToAllow = [
|
|
158
168
|
...(config.cms.domainName ? [] : [productionOrigin]),
|
|
169
|
+
...(siteOrigin ? [`${siteOrigin}/`] : []),
|
|
159
170
|
...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
|
|
160
171
|
];
|
|
161
172
|
const oauthRedirectUrls = defaultOriginsToAllow.length === 0
|
|
@@ -165,7 +176,8 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
165
176
|
appId: cmsApp.attrAppId,
|
|
166
177
|
branchName: "main",
|
|
167
178
|
stage: "PRODUCTION",
|
|
168
|
-
|
|
179
|
+
// Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
|
|
180
|
+
enableAutoBuild: !config.autoDeploy,
|
|
169
181
|
enablePerformanceMode: false,
|
|
170
182
|
environmentVariables: environmentVariables({
|
|
171
183
|
...config.cms.environment,
|
|
@@ -174,6 +186,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
174
186
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
175
187
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
176
188
|
SITE_ENV: "production",
|
|
189
|
+
PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
|
|
177
190
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
178
191
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
179
192
|
...prospectusEnvironment,
|
|
@@ -195,6 +208,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
195
208
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
196
209
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
197
210
|
SITE_ENV: "staging",
|
|
211
|
+
PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
|
|
198
212
|
...stagingMode,
|
|
199
213
|
...prospectusEnvironment,
|
|
200
214
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
@@ -247,6 +261,12 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
247
261
|
}
|
|
248
262
|
const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
|
|
249
263
|
this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
|
|
264
|
+
if (config.autoDeploy) {
|
|
265
|
+
this.addGithubDeployRole(config, [
|
|
266
|
+
{ appId: cmsApp.attrAppId, branch: "main" },
|
|
267
|
+
...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
|
|
268
|
+
]);
|
|
269
|
+
}
|
|
250
270
|
if (config.github) {
|
|
251
271
|
this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
|
|
252
272
|
}
|
|
@@ -294,6 +314,46 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
294
314
|
description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
|
|
295
315
|
});
|
|
296
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
|
|
319
|
+
* repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
|
|
320
|
+
* production branch of the site's apps.
|
|
321
|
+
*/
|
|
322
|
+
addGithubDeployRole(config, targets) {
|
|
323
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
324
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubDeployOidcProvider", this.formatArn({
|
|
325
|
+
service: "iam",
|
|
326
|
+
region: "",
|
|
327
|
+
resource: "oidc-provider",
|
|
328
|
+
resourceName: GITHUB_OIDC_PROVIDER_HOST,
|
|
329
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
330
|
+
}));
|
|
331
|
+
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
|
332
|
+
roleName: `${config.siteId}-github-deploy`,
|
|
333
|
+
description: `Merge-to-main deploy for ${owner}/${repo}: GitHub OIDC on refs/heads/main only; amplify:StartJob and GetJob on the production branch of the site's apps only`,
|
|
334
|
+
maxSessionDuration: Duration.hours(1),
|
|
335
|
+
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
336
|
+
StringEquals: {
|
|
337
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
|
|
338
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
deployRole.addToPolicy(new iam.PolicyStatement({
|
|
343
|
+
sid: "StartAndReadProductionJobs",
|
|
344
|
+
actions: ["amplify:StartJob", "amplify:GetJob"],
|
|
345
|
+
resources: targets.map((target) => this.formatArn({
|
|
346
|
+
service: "amplify",
|
|
347
|
+
resource: "apps",
|
|
348
|
+
resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
|
|
349
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
350
|
+
})),
|
|
351
|
+
}));
|
|
352
|
+
new CfnOutput(this, "GithubDeployRoleArn", {
|
|
353
|
+
value: deployRole.roleArn,
|
|
354
|
+
description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
297
357
|
addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
|
|
298
358
|
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
|
|
299
359
|
service: "iam",
|
|
@@ -309,7 +369,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
309
369
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
310
370
|
StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
|
|
311
371
|
StringLike: {
|
|
312
|
-
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.
|
|
372
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
|
|
313
373
|
},
|
|
314
374
|
}),
|
|
315
375
|
});
|
|
@@ -361,7 +421,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
361
421
|
appId: readerApp.attrAppId,
|
|
362
422
|
branchName: readerBranchName,
|
|
363
423
|
stage: "PRODUCTION",
|
|
364
|
-
enableAutoBuild:
|
|
424
|
+
enableAutoBuild: !config.autoDeploy,
|
|
365
425
|
environmentVariables: environmentVariables({
|
|
366
426
|
...(reader.environment ?? {}),
|
|
367
427
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
@@ -2,10 +2,10 @@ import { Construct } from "constructs";
|
|
|
2
2
|
import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-lib";
|
|
3
3
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
4
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
5
|
-
import
|
|
5
|
+
import { SecureStringParameter } from "./secure-string-parameter";
|
|
6
6
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
7
7
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
8
|
-
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
8
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
9
9
|
|
|
10
10
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
11
11
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
@@ -72,6 +72,9 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
72
72
|
if (!hasCmsApp(config)) {
|
|
73
73
|
if (!config.reader) throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
|
|
74
74
|
this.readerAppId = this.addReaderApp(config, config.reader);
|
|
75
|
+
if (config.autoDeploy) {
|
|
76
|
+
this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
|
|
77
|
+
}
|
|
75
78
|
return;
|
|
76
79
|
}
|
|
77
80
|
|
|
@@ -97,37 +100,39 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
97
100
|
|
|
98
101
|
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
99
102
|
if (revalidateSecretParameterName) {
|
|
103
|
+
const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
|
|
104
|
+
parameterName: revalidateSecretParameterName,
|
|
105
|
+
description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
|
|
106
|
+
});
|
|
100
107
|
computeRole.addToPolicy(
|
|
101
108
|
new iam.PolicyStatement({
|
|
102
109
|
sid: "ReadRevalidateSecret",
|
|
103
110
|
actions: ["ssm:GetParameter"],
|
|
104
|
-
resources: [
|
|
105
|
-
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
106
|
-
],
|
|
111
|
+
resources: [revalidateSecret.parameterArn],
|
|
107
112
|
}),
|
|
108
113
|
);
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
112
|
-
const
|
|
113
|
-
? new
|
|
117
|
+
const prospectusSessionSealing = prospectusAccountsApiStageArn
|
|
118
|
+
? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
|
|
119
|
+
parameterName: resolveProspectusSessionSealingParameterName(config),
|
|
114
120
|
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
115
|
-
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
116
121
|
})
|
|
117
122
|
: undefined;
|
|
118
|
-
const prospectusEnvironment = prospectusAccountsApiStageArn &&
|
|
123
|
+
const prospectusEnvironment: Record<string, string> = prospectusAccountsApiStageArn && prospectusSessionSealing
|
|
119
124
|
? {
|
|
120
125
|
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
121
|
-
|
|
126
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
|
|
122
127
|
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
123
128
|
}
|
|
124
129
|
: {};
|
|
125
|
-
if (
|
|
130
|
+
if (prospectusSessionSealing) {
|
|
126
131
|
computeRole.addToPolicy(
|
|
127
132
|
new iam.PolicyStatement({
|
|
128
133
|
sid: "ReadProspectusSessionSealingSecret",
|
|
129
|
-
actions: ["
|
|
130
|
-
resources: [
|
|
134
|
+
actions: ["ssm:GetParameter"],
|
|
135
|
+
resources: [prospectusSessionSealing.parameterArn],
|
|
131
136
|
}),
|
|
132
137
|
);
|
|
133
138
|
}
|
|
@@ -172,9 +177,15 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
172
177
|
const stagingOrigin = stagingDomainName
|
|
173
178
|
? `https://${stagingDomainName}/`
|
|
174
179
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
|
|
180
|
+
// The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
|
|
181
|
+
// A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
|
|
182
|
+
const siteOrigin = resolveSiteOrigin(config);
|
|
183
|
+
const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
|
|
184
|
+
const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
|
|
175
185
|
const sharedRootDomain = config.cms.domainPrefix !== undefined;
|
|
176
186
|
const defaultOriginsToAllow = [
|
|
177
187
|
...(config.cms.domainName ? [] : [productionOrigin]),
|
|
188
|
+
...(siteOrigin ? [`${siteOrigin}/`] : []),
|
|
178
189
|
...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
|
|
179
190
|
];
|
|
180
191
|
const oauthRedirectUrls = defaultOriginsToAllow.length === 0
|
|
@@ -185,7 +196,8 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
185
196
|
appId: cmsApp.attrAppId,
|
|
186
197
|
branchName: "main",
|
|
187
198
|
stage: "PRODUCTION",
|
|
188
|
-
|
|
199
|
+
// Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
|
|
200
|
+
enableAutoBuild: !config.autoDeploy,
|
|
189
201
|
enablePerformanceMode: false,
|
|
190
202
|
environmentVariables: environmentVariables({
|
|
191
203
|
...config.cms.environment,
|
|
@@ -194,6 +206,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
194
206
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
195
207
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
196
208
|
SITE_ENV: "production",
|
|
209
|
+
PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
|
|
197
210
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
198
211
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
199
212
|
...prospectusEnvironment,
|
|
@@ -216,6 +229,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
216
229
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
217
230
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
218
231
|
SITE_ENV: "staging",
|
|
232
|
+
PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
|
|
219
233
|
...stagingMode,
|
|
220
234
|
...prospectusEnvironment,
|
|
221
235
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
@@ -274,6 +288,13 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
274
288
|
const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
|
|
275
289
|
this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
|
|
276
290
|
|
|
291
|
+
if (config.autoDeploy) {
|
|
292
|
+
this.addGithubDeployRole(config, [
|
|
293
|
+
{ appId: cmsApp.attrAppId, branch: "main" },
|
|
294
|
+
...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
|
|
277
298
|
if (config.github) {
|
|
278
299
|
this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
|
|
279
300
|
}
|
|
@@ -326,6 +347,55 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
326
347
|
});
|
|
327
348
|
}
|
|
328
349
|
|
|
350
|
+
/**
|
|
351
|
+
* The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
|
|
352
|
+
* repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
|
|
353
|
+
* production branch of the site's apps.
|
|
354
|
+
*/
|
|
355
|
+
private addGithubDeployRole(config: AmplifyAppShellSiteConfig, targets: { appId: string; branch: string }[]): void {
|
|
356
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
357
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
|
358
|
+
this,
|
|
359
|
+
"GithubDeployOidcProvider",
|
|
360
|
+
this.formatArn({
|
|
361
|
+
service: "iam",
|
|
362
|
+
region: "",
|
|
363
|
+
resource: "oidc-provider",
|
|
364
|
+
resourceName: GITHUB_OIDC_PROVIDER_HOST,
|
|
365
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
|
369
|
+
roleName: `${config.siteId}-github-deploy`,
|
|
370
|
+
description: `Merge-to-main deploy for ${owner}/${repo}: GitHub OIDC on refs/heads/main only; amplify:StartJob and GetJob on the production branch of the site's apps only`,
|
|
371
|
+
maxSessionDuration: Duration.hours(1),
|
|
372
|
+
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
373
|
+
StringEquals: {
|
|
374
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
|
|
375
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
});
|
|
379
|
+
deployRole.addToPolicy(
|
|
380
|
+
new iam.PolicyStatement({
|
|
381
|
+
sid: "StartAndReadProductionJobs",
|
|
382
|
+
actions: ["amplify:StartJob", "amplify:GetJob"],
|
|
383
|
+
resources: targets.map((target) =>
|
|
384
|
+
this.formatArn({
|
|
385
|
+
service: "amplify",
|
|
386
|
+
resource: "apps",
|
|
387
|
+
resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
|
|
388
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
389
|
+
}),
|
|
390
|
+
),
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
new CfnOutput(this, "GithubDeployRoleArn", {
|
|
394
|
+
value: deployRole.roleArn,
|
|
395
|
+
description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
329
399
|
private addGithubCiRole(
|
|
330
400
|
config: AmplifyAppShellSiteConfig,
|
|
331
401
|
github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
|
|
@@ -351,9 +421,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
351
421
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
352
422
|
StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
|
|
353
423
|
StringLike: {
|
|
354
|
-
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.
|
|
355
|
-
(branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
|
|
356
|
-
),
|
|
424
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
|
|
357
425
|
},
|
|
358
426
|
}),
|
|
359
427
|
});
|
|
@@ -420,7 +488,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
420
488
|
appId: readerApp.attrAppId,
|
|
421
489
|
branchName: readerBranchName,
|
|
422
490
|
stage: "PRODUCTION",
|
|
423
|
-
enableAutoBuild:
|
|
491
|
+
enableAutoBuild: !config.autoDeploy,
|
|
424
492
|
environmentVariables: environmentVariables({
|
|
425
493
|
...(reader.environment ?? {}),
|
|
426
494
|
PAPYRUS_SITE_BRAND: config.brand,
|
package/infra/index.js
CHANGED
|
@@ -10,6 +10,7 @@ try {
|
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
12
|
export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
|
|
13
|
+
export const { SecureStringParameter } = await import("./secure-string-parameter.js");
|
|
13
14
|
export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
|
|
14
15
|
export { parseSiteConfig } from "./site-config.js";
|
|
15
16
|
export { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec, buildSpecFor } from "./build-specs.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source of the Lambda behind SecureStringParameter, as a string so the staged
|
|
3
|
+
* package (which ships only .ts under infra/) can inline it and tests can run it
|
|
4
|
+
* against a fake SSM client. CommonJS, under the 4096-character inline limit.
|
|
5
|
+
*
|
|
6
|
+
* It creates the parameter when it is missing and does nothing else: it never
|
|
7
|
+
* overwrites an existing value, never reads one back, never logs the value, and
|
|
8
|
+
* returns no data (only the parameter name as the physical id).
|
|
9
|
+
*/
|
|
10
|
+
export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
|
|
11
|
+
const { randomInt } = require("node:crypto");
|
|
12
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
13
|
+
function generate(length) {
|
|
14
|
+
let value = "";
|
|
15
|
+
for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
|
|
19
|
+
const name = event.ResourceProperties.ParameterName;
|
|
20
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
21
|
+
try {
|
|
22
|
+
await ssm.send(new PutParameterCommand({
|
|
23
|
+
Name: name,
|
|
24
|
+
Description: event.ResourceProperties.Description,
|
|
25
|
+
Type: "SecureString",
|
|
26
|
+
Tier: "Standard",
|
|
27
|
+
Overwrite: false,
|
|
28
|
+
Value: generate(Number(event.ResourceProperties.Length)),
|
|
29
|
+
}));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!error || error.name !== "ParameterAlreadyExists") {
|
|
32
|
+
throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { PhysicalResourceId: name };
|
|
36
|
+
};
|
|
37
|
+
exports.handler = async (event) => {
|
|
38
|
+
const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
|
|
39
|
+
return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
|
|
40
|
+
};
|
|
41
|
+
`;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source of the Lambda behind SecureStringParameter, as a string so the staged
|
|
3
|
+
* package (which ships only .ts under infra/) can inline it and tests can run it
|
|
4
|
+
* against a fake SSM client. CommonJS, under the 4096-character inline limit.
|
|
5
|
+
*
|
|
6
|
+
* It creates the parameter when it is missing and does nothing else: it never
|
|
7
|
+
* overwrites an existing value, never reads one back, never logs the value, and
|
|
8
|
+
* returns no data (only the parameter name as the physical id).
|
|
9
|
+
*/
|
|
10
|
+
export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
|
|
11
|
+
const { randomInt } = require("node:crypto");
|
|
12
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
13
|
+
function generate(length) {
|
|
14
|
+
let value = "";
|
|
15
|
+
for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
|
|
19
|
+
const name = event.ResourceProperties.ParameterName;
|
|
20
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
21
|
+
try {
|
|
22
|
+
await ssm.send(new PutParameterCommand({
|
|
23
|
+
Name: name,
|
|
24
|
+
Description: event.ResourceProperties.Description,
|
|
25
|
+
Type: "SecureString",
|
|
26
|
+
Tier: "Standard",
|
|
27
|
+
Overwrite: false,
|
|
28
|
+
Value: generate(Number(event.ResourceProperties.Length)),
|
|
29
|
+
}));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!error || error.name !== "ParameterAlreadyExists") {
|
|
32
|
+
throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { PhysicalResourceId: name };
|
|
36
|
+
};
|
|
37
|
+
exports.handler = async (event) => {
|
|
38
|
+
const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
|
|
39
|
+
return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
|
|
40
|
+
};
|
|
41
|
+
`;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Construct } from "constructs";
|
|
2
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
3
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
6
|
+
import { SECURE_STRING_PARAMETER_HANDLER_SOURCE } from "./secure-string-parameter-handler.js";
|
|
7
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
8
|
+
class SecureStringParameterProvider extends Construct {
|
|
9
|
+
static ID = "SecureStringParameterProvider";
|
|
10
|
+
role;
|
|
11
|
+
provider;
|
|
12
|
+
static of(stack) {
|
|
13
|
+
return stack.node.tryFindChild(SecureStringParameterProvider.ID)
|
|
14
|
+
?? new SecureStringParameterProvider(stack);
|
|
15
|
+
}
|
|
16
|
+
constructor(stack) {
|
|
17
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
18
|
+
this.role = new iam.Role(this, "Role", {
|
|
19
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
20
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
21
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
22
|
+
});
|
|
23
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
24
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
25
|
+
handler: "index.handler",
|
|
26
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
27
|
+
role: this.role,
|
|
28
|
+
timeout: Duration.seconds(30),
|
|
29
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
30
|
+
});
|
|
31
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (free,
|
|
36
|
+
* AWS-managed `aws/ssm` key), instead of a Secrets Manager secret ($0.40/month).
|
|
37
|
+
*
|
|
38
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts
|
|
39
|
+
* it. The Lambda generates a random value at create time and never returns or
|
|
40
|
+
* logs it, so the value is in no template, output or stack event. It is
|
|
41
|
+
* create-only: an update never regenerates it and never overwrites a parameter
|
|
42
|
+
* that already exists (a hand-made one is adopted as it is), and delete retains
|
|
43
|
+
* it, so a stack teardown cannot sign anyone out. Rotate it with
|
|
44
|
+
* `aws ssm put-parameter --overwrite`. Readers get `ssm:GetParameter` on
|
|
45
|
+
* `parameterArn` only; decrypting with the AWS-managed key needs no KMS grant.
|
|
46
|
+
*/
|
|
47
|
+
export class SecureStringParameter extends Construct {
|
|
48
|
+
parameterName;
|
|
49
|
+
parameterArn;
|
|
50
|
+
constructor(scope, id, props) {
|
|
51
|
+
super(scope, id);
|
|
52
|
+
if (!props.parameterName.startsWith("/"))
|
|
53
|
+
throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
54
|
+
const length = props.length ?? 64;
|
|
55
|
+
if (!Number.isInteger(length) || length < 32)
|
|
56
|
+
throw new Error("A generated secret must be at least 32 characters.");
|
|
57
|
+
const stack = Stack.of(this);
|
|
58
|
+
this.parameterName = props.parameterName;
|
|
59
|
+
this.parameterArn = stack.formatArn({
|
|
60
|
+
service: "ssm",
|
|
61
|
+
resource: "parameter",
|
|
62
|
+
resourceName: props.parameterName.slice(1),
|
|
63
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
64
|
+
});
|
|
65
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
66
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
67
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
68
|
+
actions: ["ssm:PutParameter"],
|
|
69
|
+
resources: [this.parameterArn],
|
|
70
|
+
}));
|
|
71
|
+
const resource = new CustomResource(this, "Resource", {
|
|
72
|
+
serviceToken: shared.provider.serviceToken,
|
|
73
|
+
resourceType: "Custom::SecureStringParameter",
|
|
74
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
75
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
76
|
+
});
|
|
77
|
+
resource.node.addDependency(shared.role);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Construct } from "constructs";
|
|
2
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
3
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
6
|
+
import { SECURE_STRING_PARAMETER_HANDLER_SOURCE } from "./secure-string-parameter-handler";
|
|
7
|
+
|
|
8
|
+
export interface SecureStringParameterProps {
|
|
9
|
+
/** Absolute parameter name, e.g. `/papyrus/<siteId>/prospectus-session-sealing`. */
|
|
10
|
+
parameterName: string;
|
|
11
|
+
description: string;
|
|
12
|
+
/** Length of the generated value. Default 64. */
|
|
13
|
+
length?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
17
|
+
class SecureStringParameterProvider extends Construct {
|
|
18
|
+
static readonly ID = "SecureStringParameterProvider";
|
|
19
|
+
public readonly role: iam.Role;
|
|
20
|
+
public readonly provider: customResources.Provider;
|
|
21
|
+
|
|
22
|
+
static of(stack: Stack): SecureStringParameterProvider {
|
|
23
|
+
return (stack.node.tryFindChild(SecureStringParameterProvider.ID) as SecureStringParameterProvider | undefined)
|
|
24
|
+
?? new SecureStringParameterProvider(stack);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private constructor(stack: Stack) {
|
|
28
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
29
|
+
this.role = new iam.Role(this, "Role", {
|
|
30
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
31
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
32
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
33
|
+
});
|
|
34
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
35
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
36
|
+
handler: "index.handler",
|
|
37
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
38
|
+
role: this.role,
|
|
39
|
+
timeout: Duration.seconds(30),
|
|
40
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
41
|
+
});
|
|
42
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (free,
|
|
48
|
+
* AWS-managed `aws/ssm` key), instead of a Secrets Manager secret ($0.40/month).
|
|
49
|
+
*
|
|
50
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts
|
|
51
|
+
* it. The Lambda generates a random value at create time and never returns or
|
|
52
|
+
* logs it, so the value is in no template, output or stack event. It is
|
|
53
|
+
* create-only: an update never regenerates it and never overwrites a parameter
|
|
54
|
+
* that already exists (a hand-made one is adopted as it is), and delete retains
|
|
55
|
+
* it, so a stack teardown cannot sign anyone out. Rotate it with
|
|
56
|
+
* `aws ssm put-parameter --overwrite`. Readers get `ssm:GetParameter` on
|
|
57
|
+
* `parameterArn` only; decrypting with the AWS-managed key needs no KMS grant.
|
|
58
|
+
*/
|
|
59
|
+
export class SecureStringParameter extends Construct {
|
|
60
|
+
public readonly parameterName: string;
|
|
61
|
+
public readonly parameterArn: string;
|
|
62
|
+
|
|
63
|
+
constructor(scope: Construct, id: string, props: SecureStringParameterProps) {
|
|
64
|
+
super(scope, id);
|
|
65
|
+
if (!props.parameterName.startsWith("/")) throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
66
|
+
const length = props.length ?? 64;
|
|
67
|
+
if (!Number.isInteger(length) || length < 32) throw new Error("A generated secret must be at least 32 characters.");
|
|
68
|
+
const stack = Stack.of(this);
|
|
69
|
+
this.parameterName = props.parameterName;
|
|
70
|
+
this.parameterArn = stack.formatArn({
|
|
71
|
+
service: "ssm",
|
|
72
|
+
resource: "parameter",
|
|
73
|
+
resourceName: props.parameterName.slice(1),
|
|
74
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
78
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
79
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
80
|
+
actions: ["ssm:PutParameter"],
|
|
81
|
+
resources: [this.parameterArn],
|
|
82
|
+
}));
|
|
83
|
+
const resource = new CustomResource(this, "Resource", {
|
|
84
|
+
serviceToken: shared.provider.serviceToken,
|
|
85
|
+
resourceType: "Custom::SecureStringParameter",
|
|
86
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
87
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
88
|
+
});
|
|
89
|
+
resource.node.addDependency(shared.role);
|
|
90
|
+
}
|
|
91
|
+
}
|