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

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,23 @@ 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
+ // - IAM (Lambda): full read+write. Both the plugin-secret-handler
440
+ // (writes ciphertext) and the trusted-processor (reads +
441
+ // decrypts) use IAM-signed DDB SDK calls.
414
442
  //
415
443
  // Storage key convention:
416
444
  // siteId = 'default' (single-site architecture)
@@ -425,17 +453,53 @@ function amplessSchemaModels(a, opts = {}) {
425
453
  siteId: a.string().required(),
426
454
  // Composite sort key: `plugins.<instanceId>.<fieldKey>`
427
455
  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.
456
+ // The secret value stored as AES-256-GCM ciphertext (base64).
457
+ // Format: base64( IV[12] || ciphertext || authTag[16] ).
458
+ // Encrypted by the plugin-secret-handler Lambda using the key
459
+ // in `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` (set by CDK at
460
+ // deploy time from the `amplify/secrets/encryption-key.ts`
461
+ // constant). Even if an AWS account operator reads this column
462
+ // via the DDB Console they only see ciphertext — the key lives
463
+ // outside DynamoDB. The honest threat model is documented in
464
+ // docs/architecture/08-plugin-architecture.md: defeated for
465
+ // DDB-only browsing, NOT defeated for anyone with source repo
466
+ // / deploy artifact access, NOT defeated for a malicious
467
+ // trusted plugin co-located in the same Lambda.
431
468
  value: a.string().required()
432
469
  }).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"])
470
+ // IAM only no Cognito group has any AppSync access.
471
+ // plugin-secret-handler Lambda writes (PutItem / DeleteItem).
472
+ // trusted-processor Lambda reads (GetItem, read-only grant
473
+ // in backend.ts via grantReadData).
474
+ allow.authenticated("iam").to(["read", "create", "update", "delete"])
475
+ ]),
476
+ // Existence-only indicator for PluginSecret rows.
477
+ //
478
+ // Admin/editor Cognito users cannot read from PluginSecret at all
479
+ // (IAM-only authorization above). But the admin UI needs to know
480
+ // whether a secret has been saved so it can show the "stored"
481
+ // indicator (••••••••) vs an empty input. PluginSecretIndicator
482
+ // solves this without ever exposing ciphertext or plaintext to the
483
+ // browser — it stores only the timestamp of the last write.
484
+ //
485
+ // The plugin-secret-handler Lambda writes to both tables atomically
486
+ // (in practice: two sequential DDB puts; true DDB transactions would
487
+ // require TransactWriteItems which adds latency; an extra indicator
488
+ // row is at most stale-indicator-but-no-secret, which degrades
489
+ // gracefully as a false "stored" indicator in the UI).
490
+ PluginSecretIndicator: a.model({
491
+ siteId: a.string().required(),
492
+ // Same sort-key format as PluginSecret:
493
+ // `plugins.${instanceId ?? name}.${fieldKey}`
494
+ sk: a.string().required(),
495
+ // ISO 8601 datetime string — set by plugin-secret-handler on
496
+ // every write. Admin UI may show this as "last updated" hint.
497
+ lastSetAt: a.datetime().required()
498
+ }).identifier(["siteId", "sk"]).authorization((allow) => [
499
+ // Admin/editor can read and write (write needed for clear).
500
+ allow.groups(["ampless-admin", "ampless-editor"]),
501
+ // plugin-secret-handler Lambda also writes via IAM.
502
+ allow.authenticated("iam").to(["read", "create", "update", "delete"])
439
503
  ]),
440
504
  // Custom return type for public post reads. Decoupling from `Post` lets
441
505
  // AppSync skip the model-level (admin-only) auth check on fields.
@@ -548,6 +612,33 @@ function amplessSchemaModels(a, opts = {}) {
548
612
  allow.publicApiKey(),
549
613
  allow.groups(["ampless-admin", "ampless-editor"])
550
614
  ]),
615
+ // Plugin secret mutation ops, only wired when the caller supplies a
616
+ // Lambda function ref. Conditionally spread because
617
+ // `a.handler.function(undefined)` is not a valid call.
618
+ //
619
+ // setPluginSecret: admin browser sends plaintext → Lambda encrypts
620
+ // (AES-256-GCM, env-var key) → DDB PutItem on
621
+ // PluginSecret + PutItem on PluginSecretIndicator.
622
+ // clearPluginSecret: Lambda deletes from both tables.
623
+ //
624
+ // Both ops require Cognito group admin-or-editor — the Lambda still
625
+ // re-checks via the Cognito token in the AppSync event context so
626
+ // a raw IAM call bypassing AppSync cannot skip the group gate.
627
+ ...opts.pluginSecretHandlerFunction ? {
628
+ setPluginSecret: a.mutation().arguments({
629
+ fieldKey: a.string().required(),
630
+ instanceId: a.string().required(),
631
+ value: a.string().required()
632
+ }).returns(a.string()).handler(a.handler.function(opts.pluginSecretHandlerFunction)).authorization((allow) => [
633
+ allow.groups(["ampless-admin", "ampless-editor"])
634
+ ]),
635
+ clearPluginSecret: a.mutation().arguments({
636
+ fieldKey: a.string().required(),
637
+ instanceId: a.string().required()
638
+ }).returns(a.string()).handler(a.handler.function(opts.pluginSecretHandlerFunction)).authorization((allow) => [
639
+ allow.groups(["ampless-admin", "ampless-editor"])
640
+ ])
641
+ } : {},
551
642
  // User management ops, only wired when the caller supplies a
552
643
  // Lambda function ref. Conditionally spread because
553
644
  // `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.47",
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",