@ampless/backend 1.0.0-alpha.45 → 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,6 +1,8 @@
|
|
|
1
1
|
// src/events/processor-trusted.ts
|
|
2
|
+
import { createDecipheriv } from "crypto";
|
|
2
3
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
3
|
-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
4
|
+
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
|
|
5
|
+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
|
|
4
6
|
import {
|
|
5
7
|
DynamoDBDocumentClient,
|
|
6
8
|
DeleteCommand,
|
|
@@ -47,6 +49,21 @@ function requireEnv(name) {
|
|
|
47
49
|
return v;
|
|
48
50
|
}
|
|
49
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
|
+
}
|
|
50
67
|
function safeParse(s) {
|
|
51
68
|
try {
|
|
52
69
|
return JSON.parse(s);
|
|
@@ -85,7 +102,24 @@ function createProcessorTrustedHandler(opts) {
|
|
|
85
102
|
const POST_TABLE = requireEnv("AMPLESS_POST_TABLE");
|
|
86
103
|
const KV_TABLE = requireEnv("AMPLESS_KV_TABLE");
|
|
87
104
|
const POSTTAG_TABLE = requireEnv("AMPLESS_POSTTAG_TABLE");
|
|
105
|
+
const PLUGIN_SECRET_TABLE = requireEnv("AMPLESS_PLUGIN_SECRET_TABLE");
|
|
88
106
|
const REGION = requireEnv("AWS_REGION");
|
|
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
|
+
}
|
|
89
123
|
async function listPublished() {
|
|
90
124
|
const items = [];
|
|
91
125
|
let exclusiveStartKey;
|
|
@@ -121,6 +155,7 @@ function createProcessorTrustedHandler(opts) {
|
|
|
121
155
|
function makeContext(plugin) {
|
|
122
156
|
const namespace = plugin.instanceId ?? plugin.name;
|
|
123
157
|
const label = plugin.instanceId ? `${plugin.name}#${plugin.instanceId}` : plugin.name;
|
|
158
|
+
const secretCache = /* @__PURE__ */ new Map();
|
|
124
159
|
return {
|
|
125
160
|
site: opts.site,
|
|
126
161
|
listPublishedPosts: () => listPublished(),
|
|
@@ -146,6 +181,58 @@ function createProcessorTrustedHandler(opts) {
|
|
|
146
181
|
})
|
|
147
182
|
);
|
|
148
183
|
return formatPublicAssetUrl(BUCKET, REGION, objectKey);
|
|
184
|
+
},
|
|
185
|
+
async secret(key) {
|
|
186
|
+
const cacheKey = `${namespace}:${key}`;
|
|
187
|
+
if (secretCache.has(cacheKey)) {
|
|
188
|
+
return secretCache.get(cacheKey);
|
|
189
|
+
}
|
|
190
|
+
const sk = `plugins.${namespace}.${key}`;
|
|
191
|
+
try {
|
|
192
|
+
const result = await rawDdb.send(
|
|
193
|
+
new GetItemCommand({
|
|
194
|
+
TableName: PLUGIN_SECRET_TABLE,
|
|
195
|
+
Key: marshall({ siteId: "default", sk })
|
|
196
|
+
})
|
|
197
|
+
);
|
|
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;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.error(
|
|
231
|
+
`[trusted-processor] ${label}: ctx.secret("${key}") DDB read failed`,
|
|
232
|
+
err
|
|
233
|
+
);
|
|
234
|
+
return void 0;
|
|
235
|
+
}
|
|
149
236
|
}
|
|
150
237
|
};
|
|
151
238
|
}
|
|
@@ -250,5 +337,6 @@ function createProcessorTrustedHandler(opts) {
|
|
|
250
337
|
};
|
|
251
338
|
}
|
|
252
339
|
export {
|
|
253
|
-
createProcessorTrustedHandler
|
|
340
|
+
createProcessorTrustedHandler,
|
|
341
|
+
decryptSecret
|
|
254
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;
|
|
@@ -175,6 +228,8 @@ declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
|
|
|
175
228
|
PostTag: any;
|
|
176
229
|
KvStore: any;
|
|
177
230
|
McpToken: any;
|
|
231
|
+
PluginSecret: any;
|
|
232
|
+
PluginSecretIndicator: any;
|
|
178
233
|
PublicPost: any;
|
|
179
234
|
PublicPostConnection: any;
|
|
180
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
|
-
|
|
207
|
+
graphqlApi.attrGraphQlUrl
|
|
187
208
|
);
|
|
188
209
|
mcpHandlerFn.addToRolePolicy(
|
|
189
210
|
new PolicyStatement({
|
|
@@ -396,6 +417,90 @@ function amplessSchemaModels(a, opts = {}) {
|
|
|
396
417
|
}).identifier(["hash"]).authorization((allow) => [
|
|
397
418
|
allow.groups(["ampless-admin"])
|
|
398
419
|
]),
|
|
420
|
+
// Isolated secret storage for trusted plugins (Phase 6a).
|
|
421
|
+
//
|
|
422
|
+
// This is a COMPLETELY SEPARATE model from KvStore. KvStore grants
|
|
423
|
+
// admin/editor full read access through AppSync, which means any
|
|
424
|
+
// value stored there can be read by anyone with admin/editor
|
|
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.
|
|
435
|
+
//
|
|
436
|
+
// Authorization design:
|
|
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.
|
|
442
|
+
//
|
|
443
|
+
// Storage key convention:
|
|
444
|
+
// siteId = 'default' (single-site architecture)
|
|
445
|
+
// sk = `plugins.${instanceId ?? name}.${fieldKey}`
|
|
446
|
+
//
|
|
447
|
+
// DynamoDB auto-encrypts at rest (AWS-managed KMS key). Secrets
|
|
448
|
+
// never flow to the S3 site-settings mirror because the trusted
|
|
449
|
+
// processor only queries KvStore (pk='siteconfig') for that path —
|
|
450
|
+
// PluginSecret is a structurally separate table.
|
|
451
|
+
PluginSecret: a.model({
|
|
452
|
+
// Single-site architecture: siteId is always 'default'.
|
|
453
|
+
siteId: a.string().required(),
|
|
454
|
+
// Composite sort key: `plugins.<instanceId>.<fieldKey>`
|
|
455
|
+
sk: a.string().required(),
|
|
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.
|
|
468
|
+
value: a.string().required()
|
|
469
|
+
}).identifier(["siteId", "sk"]).authorization((allow) => [
|
|
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"])
|
|
503
|
+
]),
|
|
399
504
|
// Custom return type for public post reads. Decoupling from `Post` lets
|
|
400
505
|
// AppSync skip the model-level (admin-only) auth check on fields.
|
|
401
506
|
//
|
|
@@ -507,6 +612,33 @@ function amplessSchemaModels(a, opts = {}) {
|
|
|
507
612
|
allow.publicApiKey(),
|
|
508
613
|
allow.groups(["ampless-admin", "ampless-editor"])
|
|
509
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
|
+
} : {},
|
|
510
642
|
// User management ops, only wired when the caller supplies a
|
|
511
643
|
// Lambda function ref. Conditionally spread because
|
|
512
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.
|
|
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.
|
|
69
|
-
"
|
|
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",
|