@anthusai/papyrus 5.13.0 → 5.15.0
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/infra/amplify-app-shell.js +15 -13
- package/infra/amplify-app-shell.ts +15 -13
- 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 +18 -2
- package/infra/site-config.ts +31 -2
- package/lib/prospectus-session.ts +14 -14
- package/package.json +1 -2
- package/routes.manifest.json +1 -1
|
@@ -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, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
7
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, 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
|
}
|
|
@@ -92,33 +92,35 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
92
92
|
});
|
|
93
93
|
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
94
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
|
+
});
|
|
95
99
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
96
100
|
sid: "ReadRevalidateSecret",
|
|
97
101
|
actions: ["ssm:GetParameter"],
|
|
98
|
-
resources: [
|
|
99
|
-
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
100
|
-
],
|
|
102
|
+
resources: [revalidateSecret.parameterArn],
|
|
101
103
|
}));
|
|
102
104
|
}
|
|
103
105
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
104
|
-
const
|
|
105
|
-
? new
|
|
106
|
+
const prospectusSessionSealing = prospectusAccountsApiStageArn
|
|
107
|
+
? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
|
|
108
|
+
parameterName: resolveProspectusSessionSealingParameterName(config),
|
|
106
109
|
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
107
|
-
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
108
110
|
})
|
|
109
111
|
: undefined;
|
|
110
|
-
const prospectusEnvironment = prospectusAccountsApiStageArn &&
|
|
112
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
|
|
111
113
|
? {
|
|
112
114
|
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
113
|
-
|
|
115
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
|
|
114
116
|
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
115
117
|
}
|
|
116
118
|
: {};
|
|
117
|
-
if (
|
|
119
|
+
if (prospectusSessionSealing) {
|
|
118
120
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
119
121
|
sid: "ReadProspectusSessionSealingSecret",
|
|
120
|
-
actions: ["
|
|
121
|
-
resources: [
|
|
122
|
+
actions: ["ssm:GetParameter"],
|
|
123
|
+
resources: [prospectusSessionSealing.parameterArn],
|
|
122
124
|
}));
|
|
123
125
|
}
|
|
124
126
|
if (prospectusAccountsApiStageArn) {
|
|
@@ -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, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
8
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, 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 }));
|
|
@@ -100,37 +100,39 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
100
100
|
|
|
101
101
|
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
102
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
|
+
});
|
|
103
107
|
computeRole.addToPolicy(
|
|
104
108
|
new iam.PolicyStatement({
|
|
105
109
|
sid: "ReadRevalidateSecret",
|
|
106
110
|
actions: ["ssm:GetParameter"],
|
|
107
|
-
resources: [
|
|
108
|
-
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
109
|
-
],
|
|
111
|
+
resources: [revalidateSecret.parameterArn],
|
|
110
112
|
}),
|
|
111
113
|
);
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
115
|
-
const
|
|
116
|
-
? new
|
|
117
|
+
const prospectusSessionSealing = prospectusAccountsApiStageArn
|
|
118
|
+
? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
|
|
119
|
+
parameterName: resolveProspectusSessionSealingParameterName(config),
|
|
117
120
|
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
118
|
-
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
119
121
|
})
|
|
120
122
|
: undefined;
|
|
121
|
-
const prospectusEnvironment = prospectusAccountsApiStageArn &&
|
|
123
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
|
|
122
124
|
? {
|
|
123
125
|
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
124
|
-
|
|
126
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
|
|
125
127
|
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
126
128
|
}
|
|
127
129
|
: {};
|
|
128
|
-
if (
|
|
130
|
+
if (prospectusSessionSealing) {
|
|
129
131
|
computeRole.addToPolicy(
|
|
130
132
|
new iam.PolicyStatement({
|
|
131
133
|
sid: "ReadProspectusSessionSealingSecret",
|
|
132
|
-
actions: ["
|
|
133
|
-
resources: [
|
|
134
|
+
actions: ["ssm:GetParameter"],
|
|
135
|
+
resources: [prospectusSessionSealing.parameterArn],
|
|
134
136
|
}),
|
|
135
137
|
);
|
|
136
138
|
}
|
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
|
+
}
|
package/infra/site-config.js
CHANGED
|
@@ -34,7 +34,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
34
34
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
35
35
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
36
36
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
37
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "
|
|
37
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
|
|
38
38
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
39
39
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
40
40
|
"GET/v1/identities/me",
|
|
@@ -59,6 +59,9 @@ function originOfRedirectUrl(value) {
|
|
|
59
59
|
return value;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
export function resolveProspectusSessionSealingParameterName(config) {
|
|
63
|
+
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
64
|
+
}
|
|
62
65
|
export function resolveRevalidateSecretParameterName(config) {
|
|
63
66
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
64
67
|
}
|
|
@@ -316,9 +319,20 @@ function validateOauthRedirectUrls(config) {
|
|
|
316
319
|
}
|
|
317
320
|
}
|
|
318
321
|
}
|
|
322
|
+
function parseAws(value) {
|
|
323
|
+
if (value === undefined)
|
|
324
|
+
return undefined;
|
|
325
|
+
const record = requireObject(value, "aws");
|
|
326
|
+
rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
|
|
327
|
+
return {
|
|
328
|
+
profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
|
|
329
|
+
accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
|
|
330
|
+
region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
319
333
|
export function parseSiteConfig(raw) {
|
|
320
334
|
const record = requireObject(raw, "site");
|
|
321
|
-
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"], "site");
|
|
335
|
+
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"], "site");
|
|
322
336
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
323
337
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
324
338
|
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
@@ -331,6 +345,7 @@ export function parseSiteConfig(raw) {
|
|
|
331
345
|
}
|
|
332
346
|
const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
333
347
|
const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
|
|
348
|
+
const aws = parseAws(record.aws);
|
|
334
349
|
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
335
350
|
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
336
351
|
const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
|
|
@@ -425,6 +440,7 @@ export function parseSiteConfig(raw) {
|
|
|
425
440
|
frontend,
|
|
426
441
|
hostedZoneId,
|
|
427
442
|
stackName,
|
|
443
|
+
aws,
|
|
428
444
|
cms,
|
|
429
445
|
reader,
|
|
430
446
|
papyrusVersion,
|
package/infra/site-config.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
53
53
|
frontend: "pretext" | "markus-static";
|
|
54
54
|
hostedZoneId?: string;
|
|
55
55
|
stackName?: string;
|
|
56
|
+
aws?: SiteAwsConfig;
|
|
56
57
|
cms?: CmsConfig;
|
|
57
58
|
reader?: {
|
|
58
59
|
appName?: string;
|
|
@@ -91,6 +92,17 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
91
92
|
};
|
|
92
93
|
};
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Where the site's AWS resources live, for operator tooling (`papyrus site google-secrets`). Not read by the
|
|
97
|
+
* stack: the deploy account comes from the CDK environment. `accountId` is what the CLI asserts with STS
|
|
98
|
+
* before it writes anything.
|
|
99
|
+
*/
|
|
100
|
+
export type SiteAwsConfig = {
|
|
101
|
+
profile?: string;
|
|
102
|
+
accountId?: string;
|
|
103
|
+
region?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
94
106
|
export type CmsSiteConfig = AmplifyAppShellSiteConfig & { cms: CmsConfig };
|
|
95
107
|
|
|
96
108
|
export function hasCmsApp(config: AmplifyAppShellSiteConfig): config is CmsSiteConfig {
|
|
@@ -131,7 +143,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
131
143
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
132
144
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
133
145
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
134
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "
|
|
146
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
|
|
135
147
|
|
|
136
148
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
137
149
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
@@ -158,6 +170,10 @@ function originOfRedirectUrl(value: string): string {
|
|
|
158
170
|
}
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
export function resolveProspectusSessionSealingParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
174
|
+
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
175
|
+
}
|
|
176
|
+
|
|
161
177
|
export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
162
178
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
163
179
|
}
|
|
@@ -426,11 +442,22 @@ function validateOauthRedirectUrls(config: CmsSiteConfig): void {
|
|
|
426
442
|
}
|
|
427
443
|
}
|
|
428
444
|
|
|
445
|
+
function parseAws(value: unknown): SiteAwsConfig | undefined {
|
|
446
|
+
if (value === undefined) return undefined;
|
|
447
|
+
const record = requireObject(value, "aws");
|
|
448
|
+
rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
|
|
449
|
+
return {
|
|
450
|
+
profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
|
|
451
|
+
accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
|
|
452
|
+
region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
429
456
|
export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
430
457
|
const record = requireObject(raw, "site");
|
|
431
458
|
rejectUnknownKeys(
|
|
432
459
|
record,
|
|
433
|
-
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"],
|
|
460
|
+
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"],
|
|
434
461
|
"site",
|
|
435
462
|
);
|
|
436
463
|
|
|
@@ -446,6 +473,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
446
473
|
}
|
|
447
474
|
const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
448
475
|
const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
|
|
476
|
+
const aws = parseAws(record.aws);
|
|
449
477
|
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
450
478
|
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
451
479
|
|
|
@@ -537,6 +565,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
537
565
|
frontend,
|
|
538
566
|
hostedZoneId,
|
|
539
567
|
stackName,
|
|
568
|
+
aws,
|
|
540
569
|
cms,
|
|
541
570
|
reader,
|
|
542
571
|
papyrusVersion,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
3
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
4
4
|
import {
|
|
5
5
|
ProspectusSiteAccounts,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
export const PROSPECTUS_PENDING_SIGN_IN_COOKIE = "__Host-papyrus-prospectus-pending";
|
|
22
22
|
export const PROSPECTUS_SESSION_COOKIE = "__Host-papyrus-prospectus-session";
|
|
23
23
|
export const PROSPECTUS_ACCOUNTS_ENDPOINT_ENV = "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT";
|
|
24
|
-
export const
|
|
24
|
+
export const PROSPECTUS_SESSION_SECRET_PARAMETER_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER";
|
|
25
25
|
export const PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
26
26
|
|
|
27
27
|
const SEALED_COOKIE_FORMAT_VERSION = "v1";
|
|
@@ -101,37 +101,37 @@ export function sameSiteReturnPath(requestedReturnPath: string | null | undefine
|
|
|
101
101
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export type ProspectusSessionSecretReader = (
|
|
104
|
+
export type ProspectusSessionSecretReader = (parameterName: string) => Promise<string | null>;
|
|
105
105
|
|
|
106
|
-
type CachedSealingSecret = {
|
|
106
|
+
type CachedSealingSecret = { parameterName: string; value: string; expiresAt: number };
|
|
107
107
|
|
|
108
108
|
let cachedSealingSecret: CachedSealingSecret | null = null;
|
|
109
|
-
let
|
|
109
|
+
let defaultSsmClient: SSMClient | null = null;
|
|
110
110
|
|
|
111
111
|
export function resetProspectusSessionSecretCache(): void {
|
|
112
112
|
cachedSealingSecret = null;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
async function
|
|
116
|
-
|
|
117
|
-
const response = await
|
|
118
|
-
return response.
|
|
115
|
+
export async function readSealingSecretFromSsm(parameterName: string, client?: Pick<SSMClient, "send">): Promise<string | null> {
|
|
116
|
+
defaultSsmClient ??= new SSMClient({});
|
|
117
|
+
const response = await (client ?? defaultSsmClient).send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
118
|
+
return response.Parameter?.Value?.trim() || null;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
export async function loadProspectusSessionSealingSecret(
|
|
122
122
|
options: { environment?: Record<string, string | undefined>; readSecret?: ProspectusSessionSecretReader; now?: number } = {},
|
|
123
123
|
): Promise<string | null> {
|
|
124
124
|
const environment = options.environment ?? process.env;
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
125
|
+
const parameterName = environment[PROSPECTUS_SESSION_SECRET_PARAMETER_ENV]?.trim();
|
|
126
|
+
if (!parameterName) return null;
|
|
127
127
|
const now = options.now ?? Date.now();
|
|
128
|
-
if (cachedSealingSecret && cachedSealingSecret.
|
|
128
|
+
if (cachedSealingSecret && cachedSealingSecret.parameterName === parameterName && cachedSealingSecret.expiresAt > now) {
|
|
129
129
|
return cachedSealingSecret.value;
|
|
130
130
|
}
|
|
131
131
|
try {
|
|
132
|
-
const value = (await (options.readSecret ??
|
|
132
|
+
const value = (await (options.readSecret ?? readSealingSecretFromSsm)(parameterName))?.trim();
|
|
133
133
|
if (!value || value.length < MINIMUM_SEALING_SECRET_LENGTH) return null;
|
|
134
|
-
cachedSealingSecret = {
|
|
134
|
+
cachedSealingSecret = { parameterName, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
|
|
135
135
|
return value;
|
|
136
136
|
} catch (error) {
|
|
137
137
|
console.error(`Prospectus session sealing secret could not be read (${error instanceof Error ? error.name : "error"}).`);
|
package/package.json
CHANGED
|
@@ -406,7 +406,7 @@
|
|
|
406
406
|
"optional": true
|
|
407
407
|
}
|
|
408
408
|
},
|
|
409
|
-
"version": "5.
|
|
409
|
+
"version": "5.15.0",
|
|
410
410
|
"dependencies": {
|
|
411
411
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
412
412
|
"@aws-crypto/sha256-js": "5.2.0",
|
|
@@ -414,7 +414,6 @@
|
|
|
414
414
|
"@aws-sdk/client-dynamodb": "^3.1045.0",
|
|
415
415
|
"@aws-sdk/client-lambda": "^3.1045.0",
|
|
416
416
|
"@aws-sdk/client-s3": "^3.1045.0",
|
|
417
|
-
"@aws-sdk/client-secrets-manager": "3.1045.0",
|
|
418
417
|
"@aws-sdk/client-ssm": "^3.1045.0",
|
|
419
418
|
"@aws-sdk/credential-provider-node": "3.972.84",
|
|
420
419
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
package/routes.manifest.json
CHANGED