@ampless/backend 1.0.0-alpha.46 → 1.0.0-alpha.48

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.
@@ -21,6 +21,12 @@ interface CreateProcessorTrustedHandlerOpts {
21
21
  */
22
22
  site: Config['site'];
23
23
  }
24
+ /**
25
+ * Decrypt one AES-256-GCM ciphertext blob.
26
+ * @param rawKey 32-byte Buffer
27
+ * @param b64 base64( IV[12] || ciphertext || authTag[16] )
28
+ */
29
+ declare function decryptSecret(rawKey: Buffer, b64: string): string;
24
30
  /**
25
31
  * SQS-driven trusted plugin executor. Trusted plugins get a runtime
26
32
  * context with `listPublishedPosts` (one Query against the byStatus
@@ -37,4 +43,4 @@ interface CreateProcessorTrustedHandlerOpts {
37
43
  */
38
44
  declare function createProcessorTrustedHandler(opts: CreateProcessorTrustedHandlerOpts): SQSHandler;
39
45
 
40
- export { type CreateProcessorTrustedHandlerOpts, createProcessorTrustedHandler };
46
+ export { type CreateProcessorTrustedHandlerOpts, createProcessorTrustedHandler, decryptSecret };
@@ -1,4 +1,5 @@
1
1
  // src/events/processor-trusted.ts
2
+ import { createDecipheriv } from "crypto";
2
3
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
3
4
  import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
4
5
  import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
@@ -48,6 +49,21 @@ function requireEnv(name) {
48
49
  return v;
49
50
  }
50
51
  var POST_BY_STATUS_INDEX = "byStatus";
52
+ function decryptSecret(rawKey, b64) {
53
+ const combined = Buffer.from(b64, "base64");
54
+ if (combined.byteLength < 12 + 16) {
55
+ throw new Error(
56
+ `[trusted-processor] decryptSecret: ciphertext blob too short (${combined.byteLength} bytes)`
57
+ );
58
+ }
59
+ const iv = combined.subarray(0, 12);
60
+ const authTag = combined.subarray(combined.byteLength - 16);
61
+ const ciphertext = combined.subarray(12, combined.byteLength - 16);
62
+ const decipher = createDecipheriv("aes-256-gcm", rawKey, iv);
63
+ decipher.setAuthTag(authTag);
64
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
65
+ return plaintext.toString("utf8");
66
+ }
51
67
  function safeParse(s) {
52
68
  try {
53
69
  return JSON.parse(s);
@@ -89,6 +105,21 @@ function createProcessorTrustedHandler(opts) {
89
105
  const PLUGIN_SECRET_TABLE = requireEnv("AMPLESS_PLUGIN_SECRET_TABLE");
90
106
  const REGION = requireEnv("AWS_REGION");
91
107
  const rawDdb = new DynamoDBClient({});
108
+ const _encKeyB64 = process.env.PLUGIN_SECRET_ENCRYPTION_KEY;
109
+ const ENCRYPTION_KEY = (() => {
110
+ if (!_encKeyB64) return null;
111
+ const buf = Buffer.from(_encKeyB64, "base64");
112
+ if (buf.byteLength !== 32) {
113
+ console.error(
114
+ `[trusted-processor] PLUGIN_SECRET_ENCRYPTION_KEY must encode 32 bytes (got ${buf.byteLength}); secrets will not be decryptable.`
115
+ );
116
+ return null;
117
+ }
118
+ return buf;
119
+ })();
120
+ function getEncryptionKey() {
121
+ return ENCRYPTION_KEY;
122
+ }
92
123
  async function listPublished() {
93
124
  const items = [];
94
125
  let exclusiveStartKey;
@@ -164,9 +195,37 @@ function createProcessorTrustedHandler(opts) {
164
195
  Key: marshall({ siteId: "default", sk })
165
196
  })
166
197
  );
167
- const value = result.Item ? unmarshall(result.Item).value : void 0;
168
- secretCache.set(cacheKey, value);
169
- return value;
198
+ if (!result.Item) {
199
+ secretCache.set(cacheKey, void 0);
200
+ return void 0;
201
+ }
202
+ const storedValue = unmarshall(result.Item).value;
203
+ if (!storedValue) {
204
+ secretCache.set(cacheKey, void 0);
205
+ return void 0;
206
+ }
207
+ const encryptionKey = getEncryptionKey();
208
+ let plaintext;
209
+ if (encryptionKey) {
210
+ try {
211
+ plaintext = decryptSecret(encryptionKey, storedValue);
212
+ } catch (decErr) {
213
+ console.error(
214
+ `[trusted-processor] ${label}: ctx.secret("${key}") decryption failed`,
215
+ decErr
216
+ );
217
+ secretCache.set(cacheKey, void 0);
218
+ return void 0;
219
+ }
220
+ } else {
221
+ console.warn(
222
+ `[trusted-processor] ${label}: ctx.secret("${key}") \u2014 no encryption key found; returning undefined. Run \`npx create-ampless setup-encryption-key\` and rotate secrets.`
223
+ );
224
+ secretCache.set(cacheKey, void 0);
225
+ return void 0;
226
+ }
227
+ secretCache.set(cacheKey, plaintext);
228
+ return plaintext;
170
229
  } catch (err) {
171
230
  console.error(
172
231
  `[trusted-processor] ${label}: ctx.secret("${key}") DDB read failed`,
@@ -278,5 +337,6 @@ function createProcessorTrustedHandler(opts) {
278
337
  };
279
338
  }
280
339
  export {
281
- createProcessorTrustedHandler
340
+ createProcessorTrustedHandler,
341
+ decryptSecret
282
342
  };
@@ -0,0 +1,32 @@
1
+ import { Handler } from 'aws-lambda';
2
+
3
+ /**
4
+ * Encrypt `plaintext` with AES-256-GCM.
5
+ * Returns base64( IV[12] || ciphertext || authTag[16] ).
6
+ * Node.js `createCipheriv` produces ciphertext and authTag separately;
7
+ * we concatenate them after the IV.
8
+ */
9
+ declare function encryptSecret(rawKey: Buffer, plaintext: string): string;
10
+ interface SetArgs {
11
+ fieldKey: string;
12
+ instanceId: string;
13
+ value: string;
14
+ }
15
+ interface ClearArgs {
16
+ fieldKey: string;
17
+ instanceId: string;
18
+ }
19
+ interface PluginSecretHandlerEvent {
20
+ typeName: string;
21
+ fieldName: string;
22
+ arguments: Partial<SetArgs & ClearArgs>;
23
+ identity?: {
24
+ sub?: string;
25
+ username?: string;
26
+ groups?: string[];
27
+ [k: string]: unknown;
28
+ };
29
+ }
30
+ declare const handler: Handler<PluginSecretHandlerEvent, string | null>;
31
+
32
+ export { encryptSecret, handler };
@@ -0,0 +1,147 @@
1
+ // src/functions/plugin-secret-handler.ts
2
+ import { createCipheriv, randomBytes } from "crypto";
3
+ import { DynamoDBClient, PutItemCommand, DeleteItemCommand } from "@aws-sdk/client-dynamodb";
4
+ import { marshall } from "@aws-sdk/util-dynamodb";
5
+ import { isValidPluginKey, validatePluginSettingValue } from "ampless";
6
+ var ddb = new DynamoDBClient({});
7
+ var ADMIN_GROUP = "ampless-admin";
8
+ var EDITOR_GROUP = "ampless-editor";
9
+ function requireEnv(name) {
10
+ const v = process.env[name];
11
+ if (!v) throw new Error(`plugin-secret-handler: missing required env var ${name}`);
12
+ return v;
13
+ }
14
+ var _encKeyB64 = process.env.PLUGIN_SECRET_ENCRYPTION_KEY;
15
+ if (!_encKeyB64) {
16
+ throw new Error(
17
+ "[plugin-secret-handler] PLUGIN_SECRET_ENCRYPTION_KEY env var is not set. Run `npx create-ampless setup-encryption-key` and configure pluginSecretEncryptionKey in defineAmplessBackend()."
18
+ );
19
+ }
20
+ var _encKeyBuf = Buffer.from(_encKeyB64, "base64");
21
+ if (_encKeyBuf.byteLength !== 32) {
22
+ throw new Error(
23
+ `[plugin-secret-handler] PLUGIN_SECRET_ENCRYPTION_KEY must encode 32 bytes (got ${_encKeyBuf.byteLength}). Re-run \`npx create-ampless setup-encryption-key\`.`
24
+ );
25
+ }
26
+ var ENCRYPTION_KEY = _encKeyBuf;
27
+ function getEncryptionKey() {
28
+ return ENCRYPTION_KEY;
29
+ }
30
+ function encryptSecret(rawKey, plaintext) {
31
+ const iv = randomBytes(12);
32
+ const cipher = createCipheriv("aes-256-gcm", rawKey, iv);
33
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
34
+ const authTag = cipher.getAuthTag();
35
+ const combined = Buffer.concat([iv, encrypted, authTag]);
36
+ return combined.toString("base64");
37
+ }
38
+ function pluginSecretSk(instanceId, fieldKey) {
39
+ return `plugins.${instanceId}.${fieldKey}`;
40
+ }
41
+ function isAllowedGroup(identity) {
42
+ const groups = identity?.groups ?? [];
43
+ return groups.includes(ADMIN_GROUP) || groups.includes(EDITOR_GROUP);
44
+ }
45
+ async function handleSet(args) {
46
+ const { fieldKey, instanceId, value } = args;
47
+ if (!isValidPluginKey(instanceId)) {
48
+ throw new Error(`[plugin-secret-handler] Invalid instanceId: "${instanceId}"`);
49
+ }
50
+ if (!isValidPluginKey(fieldKey)) {
51
+ throw new Error(`[plugin-secret-handler] Invalid fieldKey: "${fieldKey}"`);
52
+ }
53
+ if (typeof value !== "string") {
54
+ throw new Error("[plugin-secret-handler] value must be a string");
55
+ }
56
+ const validated = validatePluginSettingValue(
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ { type: "text", key: fieldKey, label: fieldKey, maxLength: 1e4 },
59
+ value,
60
+ "strict"
61
+ );
62
+ if (validated === null) {
63
+ throw new Error(
64
+ `[plugin-secret-handler] Value for field "${fieldKey}" failed validation (too long or contains invalid characters).`
65
+ );
66
+ }
67
+ const sanitizedValue = validated;
68
+ const encKey = getEncryptionKey();
69
+ const ciphertext = encryptSecret(encKey, sanitizedValue);
70
+ const sk = pluginSecretSk(instanceId, fieldKey);
71
+ const secretTable = requireEnv("AMPLESS_PLUGIN_SECRET_TABLE");
72
+ const indicatorTable = requireEnv("AMPLESS_PLUGIN_SECRET_INDICATOR_TABLE");
73
+ const now = (/* @__PURE__ */ new Date()).toISOString();
74
+ await ddb.send(
75
+ new PutItemCommand({
76
+ TableName: secretTable,
77
+ Item: marshall({ siteId: "default", sk, value: ciphertext })
78
+ })
79
+ );
80
+ await ddb.send(
81
+ new PutItemCommand({
82
+ TableName: indicatorTable,
83
+ Item: marshall({ siteId: "default", sk, lastSetAt: now })
84
+ })
85
+ );
86
+ return "ok";
87
+ }
88
+ async function handleClear(args) {
89
+ const { fieldKey, instanceId } = args;
90
+ if (!isValidPluginKey(instanceId)) {
91
+ throw new Error(`[plugin-secret-handler] Invalid instanceId: "${instanceId}"`);
92
+ }
93
+ if (!isValidPluginKey(fieldKey)) {
94
+ throw new Error(`[plugin-secret-handler] Invalid fieldKey: "${fieldKey}"`);
95
+ }
96
+ const sk = pluginSecretSk(instanceId, fieldKey);
97
+ const secretTable = requireEnv("AMPLESS_PLUGIN_SECRET_TABLE");
98
+ const indicatorTable = requireEnv("AMPLESS_PLUGIN_SECRET_INDICATOR_TABLE");
99
+ await ddb.send(
100
+ new DeleteItemCommand({
101
+ TableName: secretTable,
102
+ Key: marshall({ siteId: "default", sk })
103
+ })
104
+ );
105
+ await ddb.send(
106
+ new DeleteItemCommand({
107
+ TableName: indicatorTable,
108
+ Key: marshall({ siteId: "default", sk })
109
+ })
110
+ );
111
+ return "ok";
112
+ }
113
+ var handler = async (event) => {
114
+ const field = event.fieldName;
115
+ if (!isAllowedGroup(event.identity)) {
116
+ console.error(
117
+ `[plugin-secret-handler] ${field}: caller is not in admin or editor group`,
118
+ { identity: event.identity }
119
+ );
120
+ throw new Error("Unauthorized: caller must be in ampless-admin or ampless-editor group");
121
+ }
122
+ try {
123
+ if (field === "setPluginSecret") {
124
+ const { fieldKey, instanceId, value } = event.arguments;
125
+ if (!fieldKey || !instanceId || value === void 0) {
126
+ throw new Error("[plugin-secret-handler] setPluginSecret: missing required arguments");
127
+ }
128
+ return await handleSet({ fieldKey, instanceId, value });
129
+ }
130
+ if (field === "clearPluginSecret") {
131
+ const { fieldKey, instanceId } = event.arguments;
132
+ if (!fieldKey || !instanceId) {
133
+ throw new Error("[plugin-secret-handler] clearPluginSecret: missing required arguments");
134
+ }
135
+ return await handleClear({ fieldKey, instanceId });
136
+ }
137
+ console.error(`[plugin-secret-handler] unsupported fieldName: ${field}`);
138
+ throw new Error(`Unsupported fieldName: ${field}`);
139
+ } catch (err) {
140
+ console.error(`[plugin-secret-handler] ${field} failed:`, err);
141
+ throw err;
142
+ }
143
+ };
144
+ export {
145
+ encryptSecret,
146
+ handler
147
+ };
package/dist/index.d.ts CHANGED
@@ -15,6 +15,44 @@ interface DefineAmplessBackendOpts {
15
15
  apiKeyRenewer: FunctionResource;
16
16
  userAdmin: FunctionResource;
17
17
  mcpHandler: FunctionResource;
18
+ /**
19
+ * The plugin-secret-handler Lambda resource.
20
+ *
21
+ * Backs the `setPluginSecret` and `clearPluginSecret` AppSync mutations.
22
+ * Receives plaintext from admin browser, validates, encrypts with the
23
+ * AES-256-GCM key (injected as `PLUGIN_SECRET_ENCRYPTION_KEY` env var),
24
+ * and writes only ciphertext to DDB. The admin browser never touches
25
+ * PluginSecret directly.
26
+ *
27
+ * Generate and commit the key with `npx create-ampless setup-encryption-key`,
28
+ * then pass it via `pluginSecretEncryptionKey` below. No AWS credentials
29
+ * required for key provisioning.
30
+ *
31
+ * Optional so that existing stacks that haven't added the function yet
32
+ * don't break at TypeScript level. The function must be wired in for
33
+ * the mutations to be reachable.
34
+ */
35
+ pluginSecretHandler?: FunctionResource;
36
+ /**
37
+ * AES-256-GCM encryption key for plugin secret storage (v2.2).
38
+ *
39
+ * A base64-encoded 32-byte key generated by
40
+ * `npx create-ampless setup-encryption-key` and stored in
41
+ * `amplify/secrets/encryption-key.ts` (adjacent to `amplify/backend.ts`).
42
+ *
43
+ * Example wiring in `amplify/backend.ts`:
44
+ * ```ts
45
+ * import { PLUGIN_SECRET_ENCRYPTION_KEY } from './secrets/encryption-key.js'
46
+ * defineAmplessBackend({ ..., pluginSecretEncryptionKey: PLUGIN_SECRET_ENCRYPTION_KEY })
47
+ * ```
48
+ *
49
+ * When provided, the value is injected as the `PLUGIN_SECRET_ENCRYPTION_KEY`
50
+ * env var into both the `processorTrusted` Lambda (for decryption via
51
+ * `ctx.secret()`) and the `pluginSecretHandler` Lambda (for encryption on
52
+ * set). If omitted, the env var is not set and both Lambdas will log a
53
+ * warning and return `undefined` from `ctx.secret()`.
54
+ */
55
+ pluginSecretEncryptionKey?: string;
18
56
  }
19
57
  type AmplessBackend = any;
20
58
  /**
@@ -148,6 +186,19 @@ interface AmplessSchemaModelsOpts {
148
186
  * pattern as `AmplessAuthConfigOpts.postConfirmation`.
149
187
  */
150
188
  userAdminFunction?: unknown;
189
+ /**
190
+ * Optional Amplify `defineFunction` ref backing the plugin-secret
191
+ * mutation ops (`setPluginSecret`, `clearPluginSecret`).
192
+ *
193
+ * When supplied, the mutations are added to the schema and routed to
194
+ * this Lambda. The Lambda receives the plaintext from admin browser,
195
+ * encrypts with the env-var key, and writes ciphertext to the
196
+ * PluginSecret table. Admin/editor Cognito users never touch
197
+ * PluginSecret directly — only the Lambda (via IAM) does.
198
+ *
199
+ * Typed as `unknown` for the same reason as `userAdminFunction`.
200
+ */
201
+ pluginSecretHandlerFunction?: unknown;
151
202
  }
152
203
  /**
153
204
  * The Ampless model + custom query definitions, returned as a plain
@@ -168,6 +219,8 @@ declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
168
219
  AdminUser?: any;
169
220
  listAdminUsers?: any;
170
221
  setAdminUserRole?: any;
222
+ setPluginSecret?: any;
223
+ clearPluginSecret?: any;
171
224
  Post: any;
172
225
  Page: any;
173
226
  Media: any;
@@ -176,6 +229,7 @@ declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
176
229
  KvStore: any;
177
230
  McpToken: any;
178
231
  PluginSecret: any;
232
+ PluginSecretIndicator: any;
179
233
  PublicPost: any;
180
234
  PublicPostConnection: any;
181
235
  PublicMedia: any;
package/dist/index.js CHANGED
@@ -18,7 +18,8 @@ function defineAmplessBackend(opts) {
18
18
  processorUntrusted: opts.processorUntrusted,
19
19
  apiKeyRenewer: opts.apiKeyRenewer,
20
20
  userAdmin: opts.userAdmin,
21
- mcpHandler: opts.mcpHandler
21
+ mcpHandler: opts.mcpHandler,
22
+ ...opts.pluginSecretHandler ? { pluginSecretHandler: opts.pluginSecretHandler } : {}
22
23
  });
23
24
  const cfnBucket = backend.storage.resources.cfnResources.cfnBucket;
24
25
  cfnBucket.publicAccessBlockConfiguration = {
@@ -79,6 +80,7 @@ function defineAmplessBackend(opts) {
79
80
  "AMPLESS_USER_POOL_ID",
80
81
  backend.auth.resources.userPool.userPoolId
81
82
  );
83
+ const graphqlApi = backend.data.resources.cfnResources.cfnGraphqlApi;
82
84
  const postTable = backend.data.resources.tables["Post"];
83
85
  const cfnPostTable = backend.data.resources.cfnResources.amplifyDynamoDbTables["Post"];
84
86
  cfnPostTable.streamSpecification = { streamViewType: "NEW_AND_OLD_IMAGES" };
@@ -150,13 +152,32 @@ function defineAmplessBackend(opts) {
150
152
  ]
151
153
  })
152
154
  );
155
+ const pluginSecretTable = backend.data.resources.tables["PluginSecret"];
156
+ pluginSecretTable.grantReadData(trustedFn);
157
+ const pluginSecretIndicatorTable = backend.data.resources.tables["PluginSecretIndicator"];
153
158
  trustedFn.addEnvironment("AMPLESS_BUCKET_NAME", backend.storage.resources.bucket.bucketName);
154
159
  trustedFn.addEnvironment("AMPLESS_POST_TABLE", postTable.tableName);
155
160
  trustedFn.addEnvironment("AMPLESS_KV_TABLE", kvTable.tableName);
156
161
  trustedFn.addEnvironment("AMPLESS_POSTTAG_TABLE", postTagTable.tableName);
162
+ trustedFn.addEnvironment("AMPLESS_PLUGIN_SECRET_TABLE", pluginSecretTable.tableName);
163
+ if (opts.pluginSecretEncryptionKey) {
164
+ trustedFn.addEnvironment("PLUGIN_SECRET_ENCRYPTION_KEY", opts.pluginSecretEncryptionKey);
165
+ }
166
+ if (opts.pluginSecretHandler) {
167
+ const secretHandlerFn = backend.pluginSecretHandler.resources.lambda;
168
+ pluginSecretTable.grantReadWriteData(secretHandlerFn);
169
+ pluginSecretIndicatorTable.grantReadWriteData(secretHandlerFn);
170
+ secretHandlerFn.addEnvironment("AMPLESS_PLUGIN_SECRET_TABLE", pluginSecretTable.tableName);
171
+ secretHandlerFn.addEnvironment(
172
+ "AMPLESS_PLUGIN_SECRET_INDICATOR_TABLE",
173
+ pluginSecretIndicatorTable.tableName
174
+ );
175
+ if (opts.pluginSecretEncryptionKey) {
176
+ secretHandlerFn.addEnvironment("PLUGIN_SECRET_ENCRYPTION_KEY", opts.pluginSecretEncryptionKey);
177
+ }
178
+ }
157
179
  const untrustedFn = backend.processorUntrusted.resources.lambda;
158
180
  untrustedFn.addEventSource(new SqsEventSource(untrustedQueue, { batchSize: 5 }));
159
- const graphqlApi = backend.data.resources.cfnResources.cfnGraphqlApi;
160
181
  const apiKeyRenewerFn = backend.apiKeyRenewer.resources.lambda;
161
182
  apiKeyRenewerFn.addEnvironment("APPSYNC_API_ID", graphqlApi.attrApiId);
162
183
  apiKeyRenewerFn.addToRolePolicy(
@@ -183,7 +204,7 @@ function defineAmplessBackend(opts) {
183
204
  mcpHandlerFn.addEnvironment("AMPLESS_MCP_TOKEN_TABLE", mcpTokenTable.tableName);
184
205
  mcpHandlerFn.addEnvironment(
185
206
  "AMPLESS_APPSYNC_URL",
186
- backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl
207
+ graphqlApi.attrGraphQlUrl
187
208
  );
188
209
  mcpHandlerFn.addToRolePolicy(
189
210
  new PolicyStatement({
@@ -401,16 +422,32 @@ function amplessSchemaModels(a, opts = {}) {
401
422
  // This is a COMPLETELY SEPARATE model from KvStore. KvStore grants
402
423
  // admin/editor full read access through AppSync, which means any
403
424
  // value stored there can be read by anyone with admin/editor
404
- // credentials. PluginSecret has NO read authorization for any
405
- // Cognito groupread is exclusively granted to IAM-authenticated
406
- // principals (i.e. the trusted-processor Lambda role).
425
+ // credentials. PluginSecret is intentionally inaccessible to
426
+ // admin/editor Cognito users via AppSync the only way to write
427
+ // a secret is through the `setPluginSecret` / `clearPluginSecret`
428
+ // AppSync mutations which are backed by the plugin-secret-handler
429
+ // Lambda. The Lambda receives the plaintext, reads the encryption
430
+ // key from `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` (injected by
431
+ // CDK at deploy time from `amplify/secrets/encryption-key.ts` — see
432
+ // Phase 6a v2.2 in docs/architecture/08-plugin-architecture.md),
433
+ // and performs the DDB PutItem using its own IAM role. Ciphertext
434
+ // never flows back to the browser.
407
435
  //
408
436
  // Authorization design:
409
- // - admin / editor: CREATE, UPDATE, DELETE only. The AppSync
410
- // schema will not generate getPluginSecret / listPluginSecrets
411
- // queries for these groups because 'read' is absent.
412
- // - IAM (Lambda): read only. The trusted-processor Lambda uses
413
- // DDB SDK GetItem directly (not AppSync) to read secrets.
437
+ // - admin / editor: NO direct AppSync access. All writes go
438
+ // through the plugin-secret-handler Lambda mutation.
439
+ // - `allow.resource(pluginSecretHandlerFunction)`: grants the
440
+ // plugin-secret-handler Lambda AppSync access to this model
441
+ // (used internally by the mutation's resolver path).
442
+ // - trusted-processor Lambda reads directly via DDB SDK with the
443
+ // `grantReadData` IAM grant in `backend.ts`, bypassing AppSync
444
+ // entirely — no `@auth` rule needed for that path.
445
+ // - Fallback (when `pluginSecretHandlerFunction` is not provided
446
+ // by the caller): admin-only Cognito group. Encryption renders
447
+ // ciphertext meaningless without the env-var key, but the
448
+ // feature won't actually work without the handler — this
449
+ // fallback only exists to keep CDK synth from failing in a
450
+ // partially-wired deployment.
414
451
  //
415
452
  // Storage key convention:
416
453
  // siteId = 'default' (single-site architecture)
@@ -425,18 +462,68 @@ function amplessSchemaModels(a, opts = {}) {
425
462
  siteId: a.string().required(),
426
463
  // Composite sort key: `plugins.<instanceId>.<fieldKey>`
427
464
  sk: a.string().required(),
428
- // The secret value (plaintext string). DynamoDB's server-side
429
- // encryption (SSE) with AWS-owned KMS protects the value at
430
- // rest. IAM controls Lambda read; AppSync controls admin write.
465
+ // The secret value stored as AES-256-GCM ciphertext (base64).
466
+ // Format: base64( IV[12] || ciphertext || authTag[16] ).
467
+ // Encrypted by the plugin-secret-handler Lambda using the key
468
+ // in `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` (set by CDK at
469
+ // deploy time from the `amplify/secrets/encryption-key.ts`
470
+ // constant). Even if an AWS account operator reads this column
471
+ // via the DDB Console they only see ciphertext — the key lives
472
+ // outside DynamoDB. The honest threat model is documented in
473
+ // docs/architecture/08-plugin-architecture.md: defeated for
474
+ // DDB-only browsing, NOT defeated for anyone with source repo
475
+ // / deploy artifact access, NOT defeated for a malicious
476
+ // trusted plugin co-located in the same Lambda.
431
477
  value: a.string().required()
432
- }).identifier(["siteId", "sk"]).authorization((allow) => [
433
- // admin/editor can create/update/delete but NOT read.
434
- // 'read' is intentionally omitted so AppSync does not generate
435
- // getPluginSecret / listPluginSecrets queries for these groups.
436
- allow.groups(["ampless-admin", "ampless-editor"]).to(["create", "update", "delete"]),
437
- // Trusted Lambda reads via IAM-signed requests (SigV4).
438
- allow.authenticated("iam").to(["read"])
439
- ]),
478
+ }).identifier(["siteId", "sk"]).authorization((allow) => {
479
+ const rules = [];
480
+ if (opts.pluginSecretHandlerFunction) {
481
+ rules.push(
482
+ allow.resource(opts.pluginSecretHandlerFunction).to(["read", "create", "update", "delete"])
483
+ );
484
+ } else {
485
+ rules.push(allow.groups(["ampless-admin"]));
486
+ }
487
+ return rules;
488
+ }),
489
+ // Existence-only indicator for PluginSecret rows.
490
+ //
491
+ // Admin/editor Cognito users cannot read from PluginSecret at all
492
+ // (IAM-only authorization above). But the admin UI needs to know
493
+ // whether a secret has been saved so it can show the "stored"
494
+ // indicator (••••••••) vs an empty input. PluginSecretIndicator
495
+ // solves this without ever exposing ciphertext or plaintext to the
496
+ // browser — it stores only the timestamp of the last write.
497
+ //
498
+ // The plugin-secret-handler Lambda writes to both tables atomically
499
+ // (in practice: two sequential DDB puts; true DDB transactions would
500
+ // require TransactWriteItems which adds latency; an extra indicator
501
+ // row is at most stale-indicator-but-no-secret, which degrades
502
+ // gracefully as a false "stored" indicator in the UI).
503
+ PluginSecretIndicator: a.model({
504
+ siteId: a.string().required(),
505
+ // Same sort-key format as PluginSecret:
506
+ // `plugins.${instanceId ?? name}.${fieldKey}`
507
+ sk: a.string().required(),
508
+ // ISO 8601 datetime string — set by plugin-secret-handler on
509
+ // every write. Admin UI may show this as "last updated" hint.
510
+ lastSetAt: a.datetime().required()
511
+ }).identifier(["siteId", "sk"]).authorization((allow) => {
512
+ const rules = [
513
+ // Admin/editor have AppSync read for `hasPluginSecret` and
514
+ // direct write+delete as a fallback path (the Lambda is the
515
+ // normal write path, but allowing admin write here keeps the
516
+ // indicator and ciphertext aligned if a clear operation is
517
+ // retried after a partial failure).
518
+ allow.groups(["ampless-admin", "ampless-editor"])
519
+ ];
520
+ if (opts.pluginSecretHandlerFunction) {
521
+ rules.push(
522
+ allow.resource(opts.pluginSecretHandlerFunction).to(["read", "create", "update", "delete"])
523
+ );
524
+ }
525
+ return rules;
526
+ }),
440
527
  // Custom return type for public post reads. Decoupling from `Post` lets
441
528
  // AppSync skip the model-level (admin-only) auth check on fields.
442
529
  //
@@ -548,6 +635,33 @@ function amplessSchemaModels(a, opts = {}) {
548
635
  allow.publicApiKey(),
549
636
  allow.groups(["ampless-admin", "ampless-editor"])
550
637
  ]),
638
+ // Plugin secret mutation ops, only wired when the caller supplies a
639
+ // Lambda function ref. Conditionally spread because
640
+ // `a.handler.function(undefined)` is not a valid call.
641
+ //
642
+ // setPluginSecret: admin browser sends plaintext → Lambda encrypts
643
+ // (AES-256-GCM, env-var key) → DDB PutItem on
644
+ // PluginSecret + PutItem on PluginSecretIndicator.
645
+ // clearPluginSecret: Lambda deletes from both tables.
646
+ //
647
+ // Both ops require Cognito group admin-or-editor — the Lambda still
648
+ // re-checks via the Cognito token in the AppSync event context so
649
+ // a raw IAM call bypassing AppSync cannot skip the group gate.
650
+ ...opts.pluginSecretHandlerFunction ? {
651
+ setPluginSecret: a.mutation().arguments({
652
+ fieldKey: a.string().required(),
653
+ instanceId: a.string().required(),
654
+ value: a.string().required()
655
+ }).returns(a.string()).handler(a.handler.function(opts.pluginSecretHandlerFunction)).authorization((allow) => [
656
+ allow.groups(["ampless-admin", "ampless-editor"])
657
+ ]),
658
+ clearPluginSecret: a.mutation().arguments({
659
+ fieldKey: a.string().required(),
660
+ instanceId: a.string().required()
661
+ }).returns(a.string()).handler(a.handler.function(opts.pluginSecretHandlerFunction)).authorization((allow) => [
662
+ allow.groups(["ampless-admin", "ampless-editor"])
663
+ ])
664
+ } : {},
551
665
  // User management ops, only wired when the caller supplies a
552
666
  // Lambda function ref. Conditionally spread because
553
667
  // `a.handler.function(undefined)` is not a valid call — projects
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/backend",
3
- "version": "1.0.0-alpha.46",
3
+ "version": "1.0.0-alpha.48",
4
4
  "description": "Amplify Gen 2 backend factories for ampless: auth, data, storage, event processors, API key renewer",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,10 @@
36
36
  "./functions/mcp-handler": {
37
37
  "import": "./dist/functions/mcp-handler.js",
38
38
  "types": "./dist/functions/mcp-handler.d.ts"
39
+ },
40
+ "./functions/plugin-secret-handler": {
41
+ "import": "./dist/functions/plugin-secret-handler.js",
42
+ "types": "./dist/functions/plugin-secret-handler.d.ts"
39
43
  }
40
44
  },
41
45
  "files": [
@@ -65,8 +69,8 @@
65
69
  "@smithy/protocol-http": "^5.4.4",
66
70
  "@smithy/signature-v4": "^5.4.4",
67
71
  "fflate": "^0.8.3",
68
- "ampless": "1.0.0-alpha.29",
69
- "@ampless/mcp-server": "1.0.0-alpha.35"
72
+ "@ampless/mcp-server": "1.0.0-alpha.36",
73
+ "ampless": "1.0.0-alpha.30"
70
74
  },
71
75
  "peerDependencies": {
72
76
  "@aws-amplify/backend": "^1",