@anthusai/papyrus 5.19.1 → 5.21.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/amplify/data/resource.js +1 -0
- package/amplify/data/resource.ts +1 -0
- package/amplify/data/schema.js +1 -0
- package/amplify/data/schema.ts +1 -0
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +20 -2
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +162 -0
- package/amplify/plugins/reader-steering/config.ts +248 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +1363 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
- package/amplify/plugins/reader-steering/schema.js +140 -0
- package/amplify/plugins/reader-steering/schema.ts +142 -0
- package/amplify/plugins/registry.js +4 -0
- package/amplify/plugins/registry.ts +4 -0
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/components/moderation-panel.tsx +248 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-organizer.css +14 -0
- package/components/reference-cyclotron-priming.tsx +54 -15
- package/components/topic-steering-workspace.tsx +42 -5
- package/infra/amplify-app-shell.js +15 -1
- package/infra/amplify-app-shell.ts +17 -1
- package/infra/site-config.js +12 -2
- package/infra/site-config.ts +19 -2
- package/lib/editorial-checks.ts +6 -2
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/prospectus-session.ts +1 -0
- package/lib/prospectus-sign-in-routes.ts +2 -2
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/package.json +27 -1
- package/routes.manifest.json +158 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
|
+
import { Alarm, ComparisonOperator, MathExpression, Metric, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
|
|
3
|
+
import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
|
|
4
|
+
import { AttributeType, BillingMode, ProjectionType, Table, TableEncryption } from "aws-cdk-lib/aws-dynamodb";
|
|
5
|
+
import { ArnPrincipal, Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
6
|
+
import { Key } from "aws-cdk-lib/aws-kms";
|
|
7
|
+
import { Topic } from "aws-cdk-lib/aws-sns";
|
|
8
|
+
import { LambdaSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
|
|
9
|
+
import { Architecture, type Code, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { SecureStringParameter } from "./secure-string-parameter";
|
|
14
|
+
import { pythonLambdaCode } from "../../functions/shared/python-bundle";
|
|
15
|
+
import type { PapyrusPlugin, PluginManifest, PluginWiringContext } from "../types";
|
|
16
|
+
import { resolveNewsletterConfig, type NewsletterConfig } from "./config";
|
|
17
|
+
import { newsletterOps } from "./ops/resource";
|
|
18
|
+
import { NEWSLETTER_MUTATIONS, NEWSLETTER_QUERIES, newsletterSchemaDefinitions } from "./schema";
|
|
19
|
+
import { functionArn, newsletterFunctionName, wireSending } from "./sending";
|
|
20
|
+
|
|
21
|
+
export { resolveNewsletterConfig };
|
|
22
|
+
export type { NewsletterConfig };
|
|
23
|
+
|
|
24
|
+
export const NEWSLETTER_OUTPUT_KEY = "newsletter";
|
|
25
|
+
/** Set on the site's content-actions function: it invokes this function (`item-published`) after a real publish. Unset: no alerts. */
|
|
26
|
+
export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
|
|
27
|
+
export const NEWSLETTER_HANDLER = "papyrus_content.newsletter.handler.handler";
|
|
28
|
+
/** `boto3` is bundled because the runtime's copy may predate SESv2 `Simple.Headers` (List-Unsubscribe). */
|
|
29
|
+
export const NEWSLETTER_REQUIREMENTS = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "boto3>=1.35.8,<2"];
|
|
30
|
+
/** Relative to `amplify/functions/`, which is where the shared bundler looks for a function's `handler.py`. */
|
|
31
|
+
const FUNCTION_DIRECTORY = "../plugins/newsletter/function";
|
|
32
|
+
|
|
33
|
+
export const newsletterManifest: PluginManifest = {
|
|
34
|
+
name: "newsletter",
|
|
35
|
+
contract: 1,
|
|
36
|
+
models: [],
|
|
37
|
+
queries: NEWSLETTER_QUERIES,
|
|
38
|
+
mutations: NEWSLETTER_MUTATIONS,
|
|
39
|
+
guest: { mutations: [] },
|
|
40
|
+
newsroomPanels: [],
|
|
41
|
+
staticAssets: ["_plugins/papyrus-newsletter@1.js"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let codeForTests: Code | undefined;
|
|
45
|
+
|
|
46
|
+
/** Synthesis tests swap the bundled Python asset (a pip install) for inline code. Pass nothing to restore it. */
|
|
47
|
+
export function overrideNewsletterCodeForTests(code?: Code): void {
|
|
48
|
+
codeForTests = code;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function newsletterKeyParameterName(siteId: string): string {
|
|
52
|
+
return `/papyrus/${siteId}/newsletter-key`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The SES identity ARN and configuration set ARN the function may send through. */
|
|
56
|
+
export function newsletterSesResources(stack: Stack, config: Pick<NewsletterConfig, "sendingDomain" | "configurationSet">): string[] {
|
|
57
|
+
return [
|
|
58
|
+
`arn:${stack.partition}:ses:${stack.region}:${stack.account}:identity/${config.sendingDomain}`,
|
|
59
|
+
`arn:${stack.partition}:ses:${stack.region}:${stack.account}:configuration-set/${config.configurationSet}`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const BOUNCE_RATE_THRESHOLD = 0.04;
|
|
64
|
+
export const COMPLAINT_RATE_THRESHOLD = 0.0008;
|
|
65
|
+
export const ALARM_MINIMUM_SENDS = 50;
|
|
66
|
+
|
|
67
|
+
export function newsletterAlarmTopicName(siteId: string): string {
|
|
68
|
+
return `papyrus-newsletter-alarms-${siteId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The two alarm names; the function pauses sending only for these (python: `events.alarm_names`). */
|
|
72
|
+
export function newsletterAlarmNames(siteId: string): { bounce: string; complaint: string } {
|
|
73
|
+
return { bounce: `papyrus-newsletter-${siteId}-bounce-rate`, complaint: `papyrus-newsletter-${siteId}-complaint-rate` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What the function reads from its environment. Lists carry only what it needs to name them in the email. */
|
|
77
|
+
export function newsletterEnvironment(config: NewsletterConfig, tableName: string, keyParameter: string): Record<string, string> {
|
|
78
|
+
return {
|
|
79
|
+
TABLE_NAME: tableName,
|
|
80
|
+
KEY_PARAMETER: keyParameter,
|
|
81
|
+
SITE_ID: config.siteId,
|
|
82
|
+
SITE_ORIGIN: config.siteOrigin,
|
|
83
|
+
SENDING_DOMAIN: config.sendingDomain,
|
|
84
|
+
FROM_ADDRESS: config.fromAddress,
|
|
85
|
+
FROM_NAME: config.fromName,
|
|
86
|
+
CONFIGURATION_SET: config.configurationSet,
|
|
87
|
+
ITEM_URL_STYLE: config.itemUrlStyle,
|
|
88
|
+
INSTANT_ALERTS: config.instantAlerts ? "true" : "false",
|
|
89
|
+
LISTS_JSON: JSON.stringify(
|
|
90
|
+
config.lists.map((list) => ({
|
|
91
|
+
id: list.id,
|
|
92
|
+
name: list.name,
|
|
93
|
+
description: list.description,
|
|
94
|
+
kind: list.kind,
|
|
95
|
+
// A roundup's issue type, archive section and time zone are what drafting needs; its schedule stays in the stack.
|
|
96
|
+
...(list.kind === "roundup" ? { issueType: list.issueType, archivePath: list.archivePath, timezone: list.schedule.timezone } : {}),
|
|
97
|
+
// An alert's rule and approval decide which published items raise an alert and whether an admin approves it.
|
|
98
|
+
...(list.kind === "alert" ? { rule: list.rule, approval: list.approval } : {}),
|
|
99
|
+
})),
|
|
100
|
+
),
|
|
101
|
+
THEME_JSON: JSON.stringify(config.theme),
|
|
102
|
+
ALARM_NAMES: Object.values(newsletterAlarmNames(config.siteId)).join(","),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const newsletterPlugin: PapyrusPlugin<NewsletterConfig, { newsletterOps: typeof newsletterOps }> = {
|
|
107
|
+
manifest: newsletterManifest,
|
|
108
|
+
resolveConfig: resolveNewsletterConfig,
|
|
109
|
+
functions: () => ({ newsletterOps }),
|
|
110
|
+
schemaDefinitions: newsletterSchemaDefinitions,
|
|
111
|
+
publicSettings: (config) => ({
|
|
112
|
+
contract: newsletterManifest.contract,
|
|
113
|
+
fromName: config.fromName,
|
|
114
|
+
lists: config.lists.map((list) => ({
|
|
115
|
+
id: list.id,
|
|
116
|
+
name: list.name,
|
|
117
|
+
description: list.description,
|
|
118
|
+
kind: list.kind,
|
|
119
|
+
// The panel needs the item type to open an issue in the editor and the archive path to show where it lands.
|
|
120
|
+
...(list.kind === "roundup" ? { issueType: list.issueType, archivePath: list.archivePath } : {}),
|
|
121
|
+
})),
|
|
122
|
+
theme: config.theme,
|
|
123
|
+
}),
|
|
124
|
+
wire(backend, config, context: PluginWiringContext) {
|
|
125
|
+
const stack = Stack.of(context.createStack("newsletter"));
|
|
126
|
+
|
|
127
|
+
const key = new Key(stack, "NewsletterKey", {
|
|
128
|
+
description: `Encrypts the ${config.siteId} newsletter subscriber table`,
|
|
129
|
+
enableKeyRotation: true,
|
|
130
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
131
|
+
});
|
|
132
|
+
const table = new Table(stack, "NewsletterTable", {
|
|
133
|
+
partitionKey: { name: "pk", type: AttributeType.STRING },
|
|
134
|
+
sortKey: { name: "sk", type: AttributeType.STRING },
|
|
135
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
136
|
+
timeToLiveAttribute: "expiresAt",
|
|
137
|
+
encryption: TableEncryption.CUSTOMER_MANAGED,
|
|
138
|
+
encryptionKey: key,
|
|
139
|
+
// Point-in-time recovery stays off so that erasing a subscriber is real erasure.
|
|
140
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: false },
|
|
141
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
142
|
+
});
|
|
143
|
+
table.addGlobalSecondaryIndex({
|
|
144
|
+
indexName: "byList",
|
|
145
|
+
partitionKey: { name: "gsiList", type: AttributeType.STRING },
|
|
146
|
+
sortKey: { name: "sk", type: AttributeType.STRING },
|
|
147
|
+
projectionType: ProjectionType.KEYS_ONLY,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Sparse: only send-ledger rows carry `gsiHid`, so erasing a subscriber finds their rows with one query, not a scan.
|
|
151
|
+
table.addGlobalSecondaryIndex({
|
|
152
|
+
indexName: "byHid",
|
|
153
|
+
partitionKey: { name: "gsiHid", type: AttributeType.STRING },
|
|
154
|
+
projectionType: ProjectionType.KEYS_ONLY,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const keyParameter = new SecureStringParameter(stack, "NewsletterKeyParameter", {
|
|
158
|
+
parameterName: newsletterKeyParameterName(config.siteId),
|
|
159
|
+
description: `Root key for ${config.siteId} newsletter subscriber hashing and link signing (HKDF-derived)`,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
163
|
+
// One bundle serves the `newsletter` function and the send worker; they differ in handler, timeout and IAM.
|
|
164
|
+
const code = codeForTests ?? pythonLambdaCode(
|
|
165
|
+
{
|
|
166
|
+
functionDir: FUNCTION_DIRECTORY,
|
|
167
|
+
modules: ["papyrus_content"],
|
|
168
|
+
requirements: NEWSLETTER_REQUIREMENTS,
|
|
169
|
+
noDeps: true,
|
|
170
|
+
checkoutExtras: (outputDir) => installRequirements(path.join(functionDirectory, "function", "requirements.txt"), outputDir),
|
|
171
|
+
},
|
|
172
|
+
[
|
|
173
|
+
"bash",
|
|
174
|
+
"-c",
|
|
175
|
+
[
|
|
176
|
+
"set -euo pipefail",
|
|
177
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
178
|
+
"cp amplify/plugins/newsletter/function/handler.py /asset-output/handler.py",
|
|
179
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
180
|
+
"python3 -m pip install -r amplify/plugins/newsletter/function/requirements.txt -t /asset-output --no-cache-dir",
|
|
181
|
+
].join(" && "),
|
|
182
|
+
],
|
|
183
|
+
);
|
|
184
|
+
const environment = newsletterEnvironment(config, table.tableName, keyParameter.parameterName);
|
|
185
|
+
const lambda = new LambdaFunction(stack, "NewsletterFunction", {
|
|
186
|
+
functionName: newsletterFunctionName(config.siteId),
|
|
187
|
+
runtime: Runtime.PYTHON_3_12,
|
|
188
|
+
architecture: Architecture.ARM_64,
|
|
189
|
+
handler: NEWSLETTER_HANDLER,
|
|
190
|
+
// Starting a send pages the whole list and queues it, so this is longer than a signup needs.
|
|
191
|
+
timeout: Duration.seconds(120),
|
|
192
|
+
memorySize: 512,
|
|
193
|
+
description: `Newsletter for ${config.siteId}: subscriptions, roundup drafts, approval and starting sends`,
|
|
194
|
+
environment,
|
|
195
|
+
code,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
table.grantReadWriteData(lambda); // includes the table key's encrypt and decrypt
|
|
199
|
+
lambda.addToRolePolicy(
|
|
200
|
+
new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: [keyParameter.parameterArn] }),
|
|
201
|
+
);
|
|
202
|
+
lambda.addToRolePolicy(
|
|
203
|
+
new PolicyStatement({
|
|
204
|
+
effect: Effect.ALLOW,
|
|
205
|
+
actions: ["ses:SendEmail", "ses:SendRawEmail"],
|
|
206
|
+
resources: newsletterSesResources(stack, config),
|
|
207
|
+
conditions: { StringEquals: { "ses:FromAddress": config.fromAddress } },
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
wireSending({
|
|
211
|
+
stack,
|
|
212
|
+
config,
|
|
213
|
+
context,
|
|
214
|
+
backend,
|
|
215
|
+
table,
|
|
216
|
+
lambda,
|
|
217
|
+
environment,
|
|
218
|
+
keyParameterArn: keyParameter.parameterArn,
|
|
219
|
+
sesResources: newsletterSesResources(stack, config),
|
|
220
|
+
code,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// The SES configuration set's own sending switch, for the alarm pause. Nothing else of SES is writable.
|
|
224
|
+
lambda.addToRolePolicy(
|
|
225
|
+
new PolicyStatement({
|
|
226
|
+
effect: Effect.ALLOW,
|
|
227
|
+
actions: ["ses:PutConfigurationSetSendingOptions"],
|
|
228
|
+
resources: [newsletterSesResources(stack, config)[1]],
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
// Lifting a suppression after a deliberate re-subscribe. IAM has no resource-level scope for the account list.
|
|
232
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ses:DeleteSuppressedDestination"], resources: ["*"] }));
|
|
233
|
+
|
|
234
|
+
// SES delivery events: the topic belongs to the sending stack and is named literally.
|
|
235
|
+
const eventTopic = Topic.fromTopicArn(
|
|
236
|
+
stack,
|
|
237
|
+
"SesEventTopic",
|
|
238
|
+
`arn:${stack.partition}:sns:${stack.region}:${stack.account}:${config.eventTopicName}`,
|
|
239
|
+
);
|
|
240
|
+
eventTopic.addSubscription(new LambdaSubscription(lambda));
|
|
241
|
+
|
|
242
|
+
// Bounce and complaint rate alarms over the CloudWatch event destination, 24 h period, at least 50 sends.
|
|
243
|
+
const alarmTopic = new Topic(stack, "AlarmTopic", { topicName: newsletterAlarmTopicName(config.siteId) });
|
|
244
|
+
alarmTopic.addSubscription(new LambdaSubscription(lambda));
|
|
245
|
+
const metric = (metricName: string) =>
|
|
246
|
+
new Metric({
|
|
247
|
+
namespace: "AWS/SES",
|
|
248
|
+
metricName,
|
|
249
|
+
dimensionsMap: { "ses:configuration-set": config.configurationSet },
|
|
250
|
+
statistic: "Sum",
|
|
251
|
+
period: Duration.hours(24),
|
|
252
|
+
});
|
|
253
|
+
const names = newsletterAlarmNames(config.siteId);
|
|
254
|
+
for (const [id, alarmName, metricName, threshold] of [
|
|
255
|
+
["BounceRateAlarm", names.bounce, "Bounce", BOUNCE_RATE_THRESHOLD],
|
|
256
|
+
["ComplaintRateAlarm", names.complaint, "Complaint", COMPLAINT_RATE_THRESHOLD],
|
|
257
|
+
] as const) {
|
|
258
|
+
const rate = new MathExpression({
|
|
259
|
+
expression: `IF(sent >= ${ALARM_MINIMUM_SENDS}, FILL(events, 0) / sent, 0)`,
|
|
260
|
+
usingMetrics: { sent: metric("Send"), events: metric(metricName) },
|
|
261
|
+
period: Duration.hours(24),
|
|
262
|
+
label: `${metricName} rate`,
|
|
263
|
+
});
|
|
264
|
+
const alarm = new Alarm(stack, id, {
|
|
265
|
+
alarmName,
|
|
266
|
+
alarmDescription: `${metricName} rate above ${threshold * 100}% of sends in 24 hours (at least ${ALARM_MINIMUM_SENDS} sends): pauses ${config.siteId} newsletter sending`,
|
|
267
|
+
metric: rate,
|
|
268
|
+
threshold,
|
|
269
|
+
evaluationPeriods: 1,
|
|
270
|
+
comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
271
|
+
treatMissingData: TreatMissingData.NOT_BREACHING,
|
|
272
|
+
});
|
|
273
|
+
alarm.addAlarmAction(new SnsAction(alarmTopic));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The publish path tells this function about a real publish. The ARN is spelled out (not referenced) so the content-actions
|
|
277
|
+
// stack does not depend on this one, and the grant is for this one function only.
|
|
278
|
+
const contentActions = context.contentActionsLambda;
|
|
279
|
+
contentActions.addEnvironment(NEWSLETTER_FUNCTION_ENV, newsletterFunctionName(config.siteId));
|
|
280
|
+
contentActions.addToRolePolicy(
|
|
281
|
+
new PolicyStatement({
|
|
282
|
+
effect: Effect.ALLOW,
|
|
283
|
+
actions: ["lambda:InvokeFunction"],
|
|
284
|
+
resources: [functionArn(Stack.of(contentActions), newsletterFunctionName(config.siteId))],
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
lambda.addPermission("InvokeFromCmsCompute", {
|
|
289
|
+
principal: new ArnPrincipal(config.computeRoleArn),
|
|
290
|
+
action: "lambda:InvokeFunction",
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
backend.addOutput({ custom: { [NEWSLETTER_OUTPUT_KEY]: { actionsFunction: lambda.functionName } } });
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
function installRequirements(requirementsPath: string, outputDir: string): void {
|
|
298
|
+
// Same wheels and platform as the other Python functions: a Lambda on arm64 with CPython 3.12.
|
|
299
|
+
execFileSync(
|
|
300
|
+
"python3",
|
|
301
|
+
[
|
|
302
|
+
"-m", "pip", "install", "-r", requirementsPath, "-t", outputDir, "--no-cache-dir",
|
|
303
|
+
"--platform", "manylinux2014_aarch64", "--implementation", "cp", "--python-version", "3.12",
|
|
304
|
+
"--only-binary=:all:", "--ignore-requires-python",
|
|
305
|
+
],
|
|
306
|
+
{ stdio: "inherit" },
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { newsletterOps } from "./ops/resource.js";
|
|
3
|
+
const staff = ["editor", "admin"];
|
|
4
|
+
const admins = ["admin"];
|
|
5
|
+
/** The newsroom's newsletter operations, all JSON in and out. The function checks the groups again from the identity claims. */
|
|
6
|
+
export const NEWSLETTER_QUERIES = [
|
|
7
|
+
"newsletterIssues",
|
|
8
|
+
"newsletterIssue",
|
|
9
|
+
"newsletterPreview",
|
|
10
|
+
"newsletterOverview",
|
|
11
|
+
"newsletterIssueDeliveries",
|
|
12
|
+
"newsletterSubscribers",
|
|
13
|
+
"newsletterExport",
|
|
14
|
+
];
|
|
15
|
+
export const NEWSLETTER_MUTATIONS = [
|
|
16
|
+
"newsletterApproveIssue",
|
|
17
|
+
"newsletterCancelIssue",
|
|
18
|
+
"newsletterStopIssue",
|
|
19
|
+
"newsletterTestSend",
|
|
20
|
+
"newsletterCreateDraft",
|
|
21
|
+
"newsletterErase",
|
|
22
|
+
"newsletterResume",
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* Who AppSync admits to each operation. Approve, cancel, stop, the subscriber list, the export, erasure and resume are admin
|
|
26
|
+
* only; everything else (counts, issues, preview, test send, new draft, delivery history with masked addresses) is editors and admins.
|
|
27
|
+
*/
|
|
28
|
+
export const NEWSLETTER_OPERATION_GROUPS = {
|
|
29
|
+
newsletterIssues: staff,
|
|
30
|
+
newsletterIssue: staff,
|
|
31
|
+
newsletterPreview: staff,
|
|
32
|
+
newsletterTestSend: staff,
|
|
33
|
+
newsletterCreateDraft: staff,
|
|
34
|
+
newsletterApproveIssue: admins,
|
|
35
|
+
newsletterCancelIssue: admins,
|
|
36
|
+
newsletterStopIssue: admins,
|
|
37
|
+
newsletterOverview: staff,
|
|
38
|
+
newsletterIssueDeliveries: staff,
|
|
39
|
+
newsletterSubscribers: admins,
|
|
40
|
+
newsletterExport: admins,
|
|
41
|
+
newsletterErase: admins,
|
|
42
|
+
newsletterResume: admins,
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The newsletter plugin adds no GraphQL model: subscriber records are personal data kept in the plugin's own DynamoDB
|
|
46
|
+
* table, never readable through the content API. It adds fourteen operations for the newsroom's Newsletter panel.
|
|
47
|
+
*/
|
|
48
|
+
export function newsletterSchemaDefinitions() {
|
|
49
|
+
const handler = a.handler.function(newsletterOps);
|
|
50
|
+
const query = (name) => a.query().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
|
|
51
|
+
const mutation = (name) => a.mutation().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
|
|
52
|
+
return {
|
|
53
|
+
...Object.fromEntries(NEWSLETTER_QUERIES.map((name) => [name, query(name)])),
|
|
54
|
+
...Object.fromEntries(NEWSLETTER_MUTATIONS.map((name) => [name, mutation(name)])),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { newsletterOps } from "./ops/resource";
|
|
3
|
+
|
|
4
|
+
const staff = ["editor", "admin"];
|
|
5
|
+
const admins = ["admin"];
|
|
6
|
+
|
|
7
|
+
/** The newsroom's newsletter operations, all JSON in and out. The function checks the groups again from the identity claims. */
|
|
8
|
+
export const NEWSLETTER_QUERIES = [
|
|
9
|
+
"newsletterIssues",
|
|
10
|
+
"newsletterIssue",
|
|
11
|
+
"newsletterPreview",
|
|
12
|
+
"newsletterOverview",
|
|
13
|
+
"newsletterIssueDeliveries",
|
|
14
|
+
"newsletterSubscribers",
|
|
15
|
+
"newsletterExport",
|
|
16
|
+
];
|
|
17
|
+
export const NEWSLETTER_MUTATIONS = [
|
|
18
|
+
"newsletterApproveIssue",
|
|
19
|
+
"newsletterCancelIssue",
|
|
20
|
+
"newsletterStopIssue",
|
|
21
|
+
"newsletterTestSend",
|
|
22
|
+
"newsletterCreateDraft",
|
|
23
|
+
"newsletterErase",
|
|
24
|
+
"newsletterResume",
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Who AppSync admits to each operation. Approve, cancel, stop, the subscriber list, the export, erasure and resume are admin
|
|
28
|
+
* only; everything else (counts, issues, preview, test send, new draft, delivery history with masked addresses) is editors and admins.
|
|
29
|
+
*/
|
|
30
|
+
export const NEWSLETTER_OPERATION_GROUPS: Record<string, string[]> = {
|
|
31
|
+
newsletterIssues: staff,
|
|
32
|
+
newsletterIssue: staff,
|
|
33
|
+
newsletterPreview: staff,
|
|
34
|
+
newsletterTestSend: staff,
|
|
35
|
+
newsletterCreateDraft: staff,
|
|
36
|
+
newsletterApproveIssue: admins,
|
|
37
|
+
newsletterCancelIssue: admins,
|
|
38
|
+
newsletterStopIssue: admins,
|
|
39
|
+
newsletterOverview: staff,
|
|
40
|
+
newsletterIssueDeliveries: staff,
|
|
41
|
+
newsletterSubscribers: admins,
|
|
42
|
+
newsletterExport: admins,
|
|
43
|
+
newsletterErase: admins,
|
|
44
|
+
newsletterResume: admins,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The newsletter plugin adds no GraphQL model: subscriber records are personal data kept in the plugin's own DynamoDB
|
|
49
|
+
* table, never readable through the content API. It adds fourteen operations for the newsroom's Newsletter panel.
|
|
50
|
+
*/
|
|
51
|
+
export function newsletterSchemaDefinitions(): Record<string, unknown> {
|
|
52
|
+
const handler = a.handler.function(newsletterOps);
|
|
53
|
+
const query = (name: string) =>
|
|
54
|
+
a.query().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
|
|
55
|
+
const mutation = (name: string) =>
|
|
56
|
+
a.mutation().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
|
|
57
|
+
return {
|
|
58
|
+
...Object.fromEntries(NEWSLETTER_QUERIES.map((name) => [name, query(name)])),
|
|
59
|
+
...Object.fromEntries(NEWSLETTER_MUTATIONS.map((name) => [name, mutation(name)])),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (never Secrets Manager).
|
|
3
|
+
*
|
|
4
|
+
* This is the same construct as `infra/amplify-app-shell/lib/secure-string-parameter.ts`, copied because the Papyrus npm
|
|
5
|
+
* package ships `amplify/` but not `infra/` (the app-shell CDK app has its own tsconfig). Keep the two in step.
|
|
6
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts it: it generates the value, never returns
|
|
7
|
+
* or logs it, never overwrites an existing parameter, and delete retains it. Readers get `ssm:GetParameter` on `parameterArn`
|
|
8
|
+
* only; decrypting with the AWS-managed `aws/ssm` key needs no KMS grant.
|
|
9
|
+
*/
|
|
10
|
+
import { Construct } from "constructs";
|
|
11
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
12
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
13
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
14
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
15
|
+
/** Source of the Lambda behind SecureStringParameter, inline (CommonJS, under the 4096-character inline limit). */
|
|
16
|
+
export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
|
|
17
|
+
const { randomInt } = require("node:crypto");
|
|
18
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
19
|
+
function generate(length) {
|
|
20
|
+
let value = "";
|
|
21
|
+
for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
|
|
25
|
+
const name = event.ResourceProperties.ParameterName;
|
|
26
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
27
|
+
try {
|
|
28
|
+
await ssm.send(new PutParameterCommand({
|
|
29
|
+
Name: name,
|
|
30
|
+
Description: event.ResourceProperties.Description,
|
|
31
|
+
Type: "SecureString",
|
|
32
|
+
Tier: "Standard",
|
|
33
|
+
Overwrite: false,
|
|
34
|
+
Value: generate(Number(event.ResourceProperties.Length)),
|
|
35
|
+
}));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!error || error.name !== "ParameterAlreadyExists") {
|
|
38
|
+
throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { PhysicalResourceId: name };
|
|
42
|
+
};
|
|
43
|
+
exports.handler = async (event) => {
|
|
44
|
+
const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
|
|
45
|
+
return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
|
|
46
|
+
};
|
|
47
|
+
`;
|
|
48
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
49
|
+
class SecureStringParameterProvider extends Construct {
|
|
50
|
+
static ID = "SecureStringParameterProvider";
|
|
51
|
+
role;
|
|
52
|
+
provider;
|
|
53
|
+
static of(stack) {
|
|
54
|
+
return stack.node.tryFindChild(SecureStringParameterProvider.ID)
|
|
55
|
+
?? new SecureStringParameterProvider(stack);
|
|
56
|
+
}
|
|
57
|
+
constructor(stack) {
|
|
58
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
59
|
+
this.role = new iam.Role(this, "Role", {
|
|
60
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
61
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
62
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
63
|
+
});
|
|
64
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
65
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
66
|
+
handler: "index.handler",
|
|
67
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
68
|
+
role: this.role,
|
|
69
|
+
timeout: Duration.seconds(30),
|
|
70
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
71
|
+
});
|
|
72
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export class SecureStringParameter extends Construct {
|
|
76
|
+
parameterName;
|
|
77
|
+
parameterArn;
|
|
78
|
+
constructor(scope, id, props) {
|
|
79
|
+
super(scope, id);
|
|
80
|
+
if (!props.parameterName.startsWith("/"))
|
|
81
|
+
throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
82
|
+
const length = props.length ?? 64;
|
|
83
|
+
if (!Number.isInteger(length) || length < 32)
|
|
84
|
+
throw new Error("A generated secret must be at least 32 characters.");
|
|
85
|
+
const stack = Stack.of(this);
|
|
86
|
+
this.parameterName = props.parameterName;
|
|
87
|
+
this.parameterArn = stack.formatArn({
|
|
88
|
+
service: "ssm",
|
|
89
|
+
resource: "parameter",
|
|
90
|
+
resourceName: props.parameterName.slice(1),
|
|
91
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
92
|
+
});
|
|
93
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
94
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
95
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
96
|
+
actions: ["ssm:PutParameter"],
|
|
97
|
+
resources: [this.parameterArn],
|
|
98
|
+
}));
|
|
99
|
+
const resource = new CustomResource(this, "Resource", {
|
|
100
|
+
serviceToken: shared.provider.serviceToken,
|
|
101
|
+
resourceType: "Custom::SecureStringParameter",
|
|
102
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
103
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
104
|
+
});
|
|
105
|
+
resource.node.addDependency(shared.role);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (never Secrets Manager).
|
|
3
|
+
*
|
|
4
|
+
* This is the same construct as `infra/amplify-app-shell/lib/secure-string-parameter.ts`, copied because the Papyrus npm
|
|
5
|
+
* package ships `amplify/` but not `infra/` (the app-shell CDK app has its own tsconfig). Keep the two in step.
|
|
6
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts it: it generates the value, never returns
|
|
7
|
+
* or logs it, never overwrites an existing parameter, and delete retains it. Readers get `ssm:GetParameter` on `parameterArn`
|
|
8
|
+
* only; decrypting with the AWS-managed `aws/ssm` key needs no KMS grant.
|
|
9
|
+
*/
|
|
10
|
+
import { Construct } from "constructs";
|
|
11
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
12
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
13
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
14
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
15
|
+
|
|
16
|
+
export interface SecureStringParameterProps {
|
|
17
|
+
/** Absolute parameter name, e.g. `/papyrus/<siteId>/prospectus-session-sealing`. */
|
|
18
|
+
parameterName: string;
|
|
19
|
+
description: string;
|
|
20
|
+
/** Length of the generated value. Default 64. */
|
|
21
|
+
length?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Source of the Lambda behind SecureStringParameter, inline (CommonJS, under the 4096-character inline limit). */
|
|
25
|
+
export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
|
|
26
|
+
const { randomInt } = require("node:crypto");
|
|
27
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
28
|
+
function generate(length) {
|
|
29
|
+
let value = "";
|
|
30
|
+
for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
|
|
34
|
+
const name = event.ResourceProperties.ParameterName;
|
|
35
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
36
|
+
try {
|
|
37
|
+
await ssm.send(new PutParameterCommand({
|
|
38
|
+
Name: name,
|
|
39
|
+
Description: event.ResourceProperties.Description,
|
|
40
|
+
Type: "SecureString",
|
|
41
|
+
Tier: "Standard",
|
|
42
|
+
Overwrite: false,
|
|
43
|
+
Value: generate(Number(event.ResourceProperties.Length)),
|
|
44
|
+
}));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!error || error.name !== "ParameterAlreadyExists") {
|
|
47
|
+
throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { PhysicalResourceId: name };
|
|
51
|
+
};
|
|
52
|
+
exports.handler = async (event) => {
|
|
53
|
+
const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
|
|
54
|
+
return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
|
|
55
|
+
};
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
59
|
+
class SecureStringParameterProvider extends Construct {
|
|
60
|
+
static readonly ID = "SecureStringParameterProvider";
|
|
61
|
+
public readonly role: iam.Role;
|
|
62
|
+
public readonly provider: customResources.Provider;
|
|
63
|
+
|
|
64
|
+
static of(stack: Stack): SecureStringParameterProvider {
|
|
65
|
+
return (stack.node.tryFindChild(SecureStringParameterProvider.ID) as SecureStringParameterProvider | undefined)
|
|
66
|
+
?? new SecureStringParameterProvider(stack);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private constructor(stack: Stack) {
|
|
70
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
71
|
+
this.role = new iam.Role(this, "Role", {
|
|
72
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
73
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
74
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
75
|
+
});
|
|
76
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
77
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
78
|
+
handler: "index.handler",
|
|
79
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
80
|
+
role: this.role,
|
|
81
|
+
timeout: Duration.seconds(30),
|
|
82
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
83
|
+
});
|
|
84
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class SecureStringParameter extends Construct {
|
|
89
|
+
public readonly parameterName: string;
|
|
90
|
+
public readonly parameterArn: string;
|
|
91
|
+
|
|
92
|
+
constructor(scope: Construct, id: string, props: SecureStringParameterProps) {
|
|
93
|
+
super(scope, id);
|
|
94
|
+
if (!props.parameterName.startsWith("/")) throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
95
|
+
const length = props.length ?? 64;
|
|
96
|
+
if (!Number.isInteger(length) || length < 32) throw new Error("A generated secret must be at least 32 characters.");
|
|
97
|
+
const stack = Stack.of(this);
|
|
98
|
+
this.parameterName = props.parameterName;
|
|
99
|
+
this.parameterArn = stack.formatArn({
|
|
100
|
+
service: "ssm",
|
|
101
|
+
resource: "parameter",
|
|
102
|
+
resourceName: props.parameterName.slice(1),
|
|
103
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
107
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
108
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
109
|
+
actions: ["ssm:PutParameter"],
|
|
110
|
+
resources: [this.parameterArn],
|
|
111
|
+
}));
|
|
112
|
+
const resource = new CustomResource(this, "Resource", {
|
|
113
|
+
serviceToken: shared.provider.serviceToken,
|
|
114
|
+
resourceType: "Custom::SecureStringParameter",
|
|
115
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
116
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
117
|
+
});
|
|
118
|
+
resource.node.addDependency(shared.role);
|
|
119
|
+
}
|
|
120
|
+
}
|