@friggframework/core 2.0.0--canary.654.ebad223.0 → 2.0.0--canary.651.dadf4d6.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/.eslintrc.json +1 -18
- package/CLAUDE.md +62 -14
- package/README.md +22 -19
- package/core/CLAUDE.md +12 -25
- package/core/Worker.js +37 -31
- package/core/create-handler.js +197 -96
- package/core/index.js +1 -8
- package/core/secrets-to-env.js +6 -7
- package/database/config.js +7 -0
- package/database/documentdb-encryption-service.js +6 -2
- package/database/documentdb-utils.js +2 -1
- package/database/encryption/README.md +21 -5
- package/database/encryption/encryption-schema-registry.js +0 -3
- package/database/encryption/integration-mapping-encryption.js +97 -0
- package/database/encryption/prisma-encryption-extension.js +20 -9
- package/errors/fetch-error.js +65 -48
- package/handlers/app-definition-loader.js +2 -53
- package/handlers/app-handler-helpers.js +4 -18
- package/handlers/backend-utils.js +5 -0
- package/handlers/database-migration-handler.js +1 -10
- package/handlers/routers/websocket.js +6 -26
- package/handlers/workers/db-migration.js +1 -12
- package/handlers/workers/dlq-processor.js +38 -64
- package/index.js +1 -19
- package/integrations/integration-base.js +38 -56
- package/integrations/integration-router.js +20 -21
- package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
- package/integrations/repositories/integration-mapping-query.js +193 -0
- package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
- package/integrations/use-cases/delete-integration-for-user.js +13 -24
- package/jest.config.js +0 -2
- package/logs/.eslintrc.json +1 -26
- package/logs/index.js +3 -14
- package/logs/logger.js +42 -132
- package/modules/module.js +19 -32
- package/modules/requester/oauth-2.js +22 -28
- package/modules/requester/requester.js +6 -69
- package/package.json +6 -5
- package/queues/queuer-util.js +15 -1
- package/telemetry/bind-telemetry-context.js +0 -4
- package/telemetry/instrument-handler.js +2 -19
- package/telemetry/no-op-telemetry.js +0 -4
- package/telemetry/otel-telemetry.js +6 -25
- package/telemetry/telemetry-context.js +8 -19
- package/telemetry/telemetry-runtime.js +0 -17
- package/telemetry/telemetry-service.js +0 -1
- package/types/errors/index.d.ts +10 -24
- package/types/logs/index.d.ts +2 -61
- package/user/repositories/user-repository-documentdb.js +40 -8
- package/core/invocation-scope.js +0 -224
- package/logs/__fixtures__/events.js +0 -82
- package/logs/__fixtures__/matchers.js +0 -45
- package/logs/__fixtures__/secrets.js +0 -22
- package/logs/__fixtures__/vectors.js +0 -329
- package/logs/__fixtures__/with-env.js +0 -45
- package/logs/context.js +0 -92
- package/logs/debug-shims.js +0 -46
- package/logs/jest-logger-setup.js +0 -25
- package/logs/levels.js +0 -71
- package/logs/logger-runtime.js +0 -142
- package/logs/record.js +0 -231
- package/logs/redact.js +0 -333
- package/logs/serialize.js +0 -322
- package/logs/sinks.js +0 -99
- package/logs/summarize-event.js +0 -162
package/core/create-handler.js
CHANGED
|
@@ -2,16 +2,7 @@
|
|
|
2
2
|
// REMOVING FOR NOW UNTIL WE ADD WEBPACK BACK IN
|
|
3
3
|
// require('source-map-support').install();
|
|
4
4
|
|
|
5
|
-
const {
|
|
6
|
-
const {
|
|
7
|
-
summarizeLambdaEvent,
|
|
8
|
-
toScopeInvocation,
|
|
9
|
-
toRequestInvocation,
|
|
10
|
-
} = require('../logs/summarize-event');
|
|
11
|
-
const {
|
|
12
|
-
runInvocationScope,
|
|
13
|
-
DEFAULT_FLUSH_TIMEOUT_MS,
|
|
14
|
-
} = require('./invocation-scope');
|
|
5
|
+
const { initDebugLog, flushDebugLog } = require('../logs');
|
|
15
6
|
const { secretsToEnv } = require('./secrets-to-env');
|
|
16
7
|
const { parametersToEnv } = require('./parameters-to-env');
|
|
17
8
|
const {
|
|
@@ -20,7 +11,123 @@ const {
|
|
|
20
11
|
getPluginTelemetrySubscribers,
|
|
21
12
|
} = require('../telemetry/telemetry-runtime');
|
|
22
13
|
|
|
23
|
-
|
|
14
|
+
// Bounds the tail latency telemetry adds to every warm invocation. Kept low so
|
|
15
|
+
// an unreachable OTLP endpoint (e.g. a VPC Lambda with no NAT/egress) costs at
|
|
16
|
+
// most this, not multiple seconds. Override with OTEL_FLUSH_TIMEOUT_MS.
|
|
17
|
+
const DEFAULT_FLUSH_TIMEOUT_MS =
|
|
18
|
+
Number(process.env.OTEL_FLUSH_TIMEOUT_MS) || 500;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fold the invocation's buffered usage counters into the durable store, then
|
|
22
|
+
* clear the buffer. On an SQS redelivery (ApproximateReceiveCount > 1) we
|
|
23
|
+
* DISCARD rather than flush — the prior delivery already counted, and the usage
|
|
24
|
+
* accuracy contract is "approximate, skip obvious redeliveries". Fully guarded.
|
|
25
|
+
*/
|
|
26
|
+
async function flushUsageRollup(subscriber, eventSummary, shouldUseDatabase) {
|
|
27
|
+
if (!subscriber) return;
|
|
28
|
+
try {
|
|
29
|
+
// Persisting usage requires a DB connection. DB-free handlers (e.g. the
|
|
30
|
+
// webhook-receipt route) never called connectPrisma, so drop the buffer
|
|
31
|
+
// instead of issuing a connectionless Prisma write.
|
|
32
|
+
if (!shouldUseDatabase) {
|
|
33
|
+
subscriber.discard();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Discard only when EVERY record in the batch is a redelivery. The buffer
|
|
37
|
+
// is invocation-scoped (not per-message), so discarding on *any*
|
|
38
|
+
// redelivery would drop the fresh records' counts too (silent
|
|
39
|
+
// under-count). For a mixed batch we flush: preserving fresh counts and
|
|
40
|
+
// at worst re-counting the one redelivered record is strictly better than
|
|
41
|
+
// losing fresh data for an approximate store. (Integration queue workers
|
|
42
|
+
// are batchSize:1 today, so a batch is all-or-nothing; this keeps it
|
|
43
|
+
// correct if batchSize is ever raised.)
|
|
44
|
+
const records = Array.isArray(eventSummary?.records)
|
|
45
|
+
? eventSummary.records
|
|
46
|
+
: [];
|
|
47
|
+
const allRedelivered =
|
|
48
|
+
records.length > 0 &&
|
|
49
|
+
records.every((r) => Number(r.receiveCount) > 1);
|
|
50
|
+
if (allRedelivered) {
|
|
51
|
+
subscriber.discard();
|
|
52
|
+
} else {
|
|
53
|
+
await subscriber.flush();
|
|
54
|
+
}
|
|
55
|
+
} catch (_) {
|
|
56
|
+
// Usage rollup must never break the handler.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Flush telemetry before the Lambda container freezes. Because
|
|
62
|
+
* `callbackWaitsForEmptyEventLoop=false` (below) stops the event loop the moment
|
|
63
|
+
* the handler returns, OTel's timer-driven batch processors would never fire —
|
|
64
|
+
* so spans/metrics must be flushed synchronously here. Bounded by a timeout so a
|
|
65
|
+
* stalled exporter can never block the response, and fully guarded so a flush
|
|
66
|
+
* failure never breaks the handler.
|
|
67
|
+
*/
|
|
68
|
+
async function flushTelemetry(telemetry, timeoutMs) {
|
|
69
|
+
try {
|
|
70
|
+
if (
|
|
71
|
+
!telemetry ||
|
|
72
|
+
typeof telemetry.isEnabled !== 'function' ||
|
|
73
|
+
!telemetry.isEnabled()
|
|
74
|
+
) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let timer;
|
|
78
|
+
const deadline = new Promise((resolve) => {
|
|
79
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
await Promise.race([
|
|
83
|
+
Promise.resolve(telemetry.forceFlush()),
|
|
84
|
+
deadline,
|
|
85
|
+
]);
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
} catch (_) {
|
|
90
|
+
// Telemetry flush must never break the handler.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Best-effort extraction of correlation identifiers from a Lambda event.
|
|
95
|
+
// For SQS: pulls messageIds + parsed event/processId/integrationId from each
|
|
96
|
+
// record body. For HTTP: pulls method+path. Never throws.
|
|
97
|
+
const summarizeLambdaEvent = (event) => {
|
|
98
|
+
if (!event) return {};
|
|
99
|
+
if (Array.isArray(event.Records)) {
|
|
100
|
+
return {
|
|
101
|
+
source: 'sqs',
|
|
102
|
+
records: event.Records.map((r) => {
|
|
103
|
+
let parsed = {};
|
|
104
|
+
try {
|
|
105
|
+
const body = JSON.parse(r.body);
|
|
106
|
+
parsed = {
|
|
107
|
+
event: body?.event,
|
|
108
|
+
processId: body?.data?.processId,
|
|
109
|
+
integrationId: body?.data?.integrationId,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
// ignore unparseable bodies
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
messageId: r.messageId,
|
|
116
|
+
receiveCount: r.attributes?.ApproximateReceiveCount,
|
|
117
|
+
...parsed,
|
|
118
|
+
};
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (event.httpMethod || event.requestContext?.http) {
|
|
123
|
+
return {
|
|
124
|
+
source: 'http',
|
|
125
|
+
method: event.httpMethod || event.requestContext?.http?.method,
|
|
126
|
+
path: event.path || event.rawPath,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { source: 'other' };
|
|
130
|
+
};
|
|
24
131
|
|
|
25
132
|
const createHandler = (optionByName = {}) => {
|
|
26
133
|
const {
|
|
@@ -50,101 +157,95 @@ const createHandler = (optionByName = {}) => {
|
|
|
50
157
|
// no-op after the first invocation.
|
|
51
158
|
getPluginTelemetrySubscribers();
|
|
52
159
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
invocation: toScopeInvocation(eventSummary),
|
|
60
|
-
};
|
|
61
|
-
// The full redacted summary goes only on the entry and failure records.
|
|
62
|
-
const requestInvocation = toRequestInvocation(eventSummary);
|
|
160
|
+
try {
|
|
161
|
+
console.info(`[createHandler] ${eventName}: handler entry`, {
|
|
162
|
+
eventName,
|
|
163
|
+
awsRequestId: context?.awsRequestId,
|
|
164
|
+
...eventSummary,
|
|
165
|
+
});
|
|
63
166
|
|
|
64
|
-
|
|
65
|
-
scopeFields,
|
|
66
|
-
async () => {
|
|
67
|
-
try {
|
|
68
|
-
log.info('Handler invoked', {
|
|
69
|
-
eventName: 'frigg.handler.invoked',
|
|
70
|
-
invocation: requestInvocation,
|
|
71
|
-
});
|
|
167
|
+
initDebugLog(eventName, event);
|
|
72
168
|
|
|
73
|
-
|
|
74
|
-
|
|
169
|
+
const requestMethod = event.httpMethod;
|
|
170
|
+
const requestPath = event.path;
|
|
171
|
+
if (requestMethod && requestPath) {
|
|
172
|
+
console.info(`${requestMethod} ${requestPath}`);
|
|
173
|
+
}
|
|
75
174
|
|
|
76
|
-
|
|
77
|
-
|
|
175
|
+
// If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
|
|
176
|
+
await secretsToEnv();
|
|
78
177
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const { connectPrisma } = require('../database/prisma');
|
|
82
|
-
await connectPrisma();
|
|
83
|
-
}
|
|
178
|
+
// If enabled (i.e. if SSM_PARAMETER_PREFIX and FRIGG_SSM_OFFLOADED_KEYS are set) fetch offloaded params from SSM Parameter Store into process.env.
|
|
179
|
+
await parametersToEnv();
|
|
84
180
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
} catch (error) {
|
|
91
|
-
// Allow client-safe errors to pass through with their actual message
|
|
92
|
-
if (isUserFacingResponse && error.isClientSafe === true) {
|
|
93
|
-
const statusCode = error.statusCode || 400;
|
|
94
|
-
log.warn('Request rejected', {
|
|
95
|
-
eventName: 'frigg.handler.rejected',
|
|
96
|
-
statusCode,
|
|
97
|
-
error,
|
|
98
|
-
});
|
|
99
|
-
return {
|
|
100
|
-
statusCode,
|
|
101
|
-
body: JSON.stringify({
|
|
102
|
-
error: error.message,
|
|
103
|
-
}),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
181
|
+
// Lazy-required so DB-free handlers never load the Prisma client.
|
|
182
|
+
if (shouldUseDatabase) {
|
|
183
|
+
const { connectPrisma } = require('../database/prisma');
|
|
184
|
+
await connectPrisma();
|
|
185
|
+
}
|
|
106
186
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
187
|
+
// Helps reuse the database connection. Lowers response times.
|
|
188
|
+
context.callbackWaitsForEmptyEventLoop = false;
|
|
189
|
+
|
|
190
|
+
// Run the Lambda
|
|
191
|
+
return await method(event, context);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
flushDebugLog(error);
|
|
194
|
+
|
|
195
|
+
// Don't leak implementation details to end users.
|
|
196
|
+
if (isUserFacingResponse) {
|
|
197
|
+
// Allow client-safe errors to pass through with their actual message
|
|
198
|
+
if (error.isClientSafe === true) {
|
|
199
|
+
const statusCode = error.statusCode || 400;
|
|
200
|
+
return {
|
|
201
|
+
statusCode,
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
error: error.message,
|
|
204
|
+
}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Hide other errors with generic message
|
|
209
|
+
return {
|
|
210
|
+
statusCode: 500,
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
error: 'An Internal Error Occurred',
|
|
213
|
+
}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
117
216
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
217
|
+
// Handle server-to-server responses.
|
|
218
|
+
|
|
219
|
+
// Halt errors are logged but suceed and won't be retried.
|
|
220
|
+
// Log explicitly — silent suppression here previously made stuck
|
|
221
|
+
// messages invisible to observability tooling. Include
|
|
222
|
+
// eventSummary so operators can correlate across concurrent
|
|
223
|
+
// invocations (processId / messageIds / HTTP path).
|
|
224
|
+
if (error.isHaltError === true) {
|
|
225
|
+
console.warn(
|
|
226
|
+
`[createHandler] ${eventName}: halt error suppressed (no retry)`,
|
|
227
|
+
{
|
|
228
|
+
eventName,
|
|
229
|
+
errorName: error.name,
|
|
230
|
+
errorMessage: error.message,
|
|
231
|
+
statusCode: error.statusCode,
|
|
232
|
+
...eventSummary,
|
|
132
233
|
}
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
133
237
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
usageRollup: activeUsageRollup,
|
|
238
|
+
// Here we can just rethrow and let AWS build the response.
|
|
239
|
+
throw error;
|
|
240
|
+
} finally {
|
|
241
|
+
// Flush telemetry + usage before the container freezes.
|
|
242
|
+
await flushTelemetry(activeTelemetry, flushTimeoutMs);
|
|
243
|
+
await flushUsageRollup(
|
|
244
|
+
activeUsageRollup,
|
|
142
245
|
eventSummary,
|
|
143
|
-
shouldUseDatabase
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
);
|
|
246
|
+
shouldUseDatabase
|
|
247
|
+
);
|
|
248
|
+
}
|
|
148
249
|
};
|
|
149
250
|
};
|
|
150
251
|
|
package/core/index.js
CHANGED
|
@@ -2,12 +2,5 @@ const { Delegate } = require('./Delegate');
|
|
|
2
2
|
const { Worker } = require('./Worker');
|
|
3
3
|
const { loadInstalledModules } = require('./load-installed-modules');
|
|
4
4
|
const { createHandler } = require('./create-handler');
|
|
5
|
-
const { runInvocationScope } = require('./invocation-scope');
|
|
6
5
|
|
|
7
|
-
module.exports = {
|
|
8
|
-
Delegate,
|
|
9
|
-
Worker,
|
|
10
|
-
loadInstalledModules,
|
|
11
|
-
createHandler,
|
|
12
|
-
runInvocationScope,
|
|
13
|
-
};
|
|
6
|
+
module.exports = { Delegate, Worker, loadInstalledModules, createHandler };
|
package/core/secrets-to-env.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
const { getLogger } = require('../logs');
|
|
2
|
-
|
|
3
|
-
const log = getLogger('frigg.core.secrets');
|
|
4
|
-
|
|
5
1
|
const getSecretValue = async () => {
|
|
6
|
-
log
|
|
2
|
+
console.log('Fetching secrets...');
|
|
7
3
|
|
|
8
4
|
const httpPort = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT || 2773;
|
|
9
5
|
const url = `http://localhost:${httpPort}/secretsmanager/get?secretId=${encodeURIComponent(
|
|
@@ -19,7 +15,10 @@ const getSecretValue = async () => {
|
|
|
19
15
|
const response = await fetch(url, options);
|
|
20
16
|
|
|
21
17
|
if (!response.ok) {
|
|
22
|
-
|
|
18
|
+
const json = await response.json().catch((err) => err.message);
|
|
19
|
+
console.error('Invalid response - response:', JSON.stringify(response));
|
|
20
|
+
console.error('Invalid response - json:', json);
|
|
21
|
+
throw new Error(`Invalid ${response.status} response`);
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
const result = await response.json();
|
|
@@ -45,7 +44,7 @@ const secretsToEnv = async () => {
|
|
|
45
44
|
if (!process.env.SECRET_ARN) {
|
|
46
45
|
return;
|
|
47
46
|
}
|
|
48
|
-
log
|
|
47
|
+
console.log('Secrets to env');
|
|
49
48
|
|
|
50
49
|
try {
|
|
51
50
|
const secrets = await getSecretValue();
|
package/database/config.js
CHANGED
|
@@ -125,9 +125,16 @@ let cachedDbType = null;
|
|
|
125
125
|
*/
|
|
126
126
|
const PRISMA_LOG_LEVEL = process.env.PRISMA_LOG_LEVEL || 'error,warn';
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Enable Prisma query logging for performance monitoring
|
|
130
|
+
* @type {boolean}
|
|
131
|
+
*/
|
|
132
|
+
const PRISMA_QUERY_LOGGING = process.env.PRISMA_QUERY_LOGGING === 'true';
|
|
133
|
+
|
|
128
134
|
module.exports = {
|
|
129
135
|
getDatabaseType, // Export for testing and direct use
|
|
130
136
|
PRISMA_LOG_LEVEL,
|
|
137
|
+
PRISMA_QUERY_LOGGING,
|
|
131
138
|
};
|
|
132
139
|
|
|
133
140
|
/**
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
2
|
-
const {
|
|
2
|
+
const {
|
|
3
|
+
getEncryptedFields,
|
|
4
|
+
getFieldsToEncryptOnWrite,
|
|
5
|
+
loadCustomEncryptionSchema,
|
|
6
|
+
} = require('./encryption/encryption-schema-registry');
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Encryption service specifically for DocumentDB repositories
|
|
@@ -107,7 +111,7 @@ class DocumentDBEncryptionService {
|
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
// Get encrypted fields from registry
|
|
110
|
-
const encryptedFieldsConfig =
|
|
114
|
+
const encryptedFieldsConfig = getFieldsToEncryptOnWrite(modelName);
|
|
111
115
|
if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
|
|
112
116
|
return document;
|
|
113
117
|
}
|
|
@@ -166,11 +166,12 @@ async function findManyDrained(client, collection, filter = {}, options = {}) {
|
|
|
166
166
|
return drainCursor(client, collection, first);
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
async function aggregateDrained(client, collection, pipeline) {
|
|
169
|
+
async function aggregateDrained(client, collection, pipeline, options = {}) {
|
|
170
170
|
const first = await client.$runCommandRaw({
|
|
171
171
|
aggregate: collection,
|
|
172
172
|
pipeline,
|
|
173
173
|
cursor: { batchSize: DRAIN_BATCH_SIZE },
|
|
174
|
+
...options,
|
|
174
175
|
});
|
|
175
176
|
return drainCursor(client, collection, first);
|
|
176
177
|
}
|
|
@@ -680,11 +680,7 @@ The encryption system uses **fail-fast error handling**:
|
|
|
680
680
|
|
|
681
681
|
### Logging Configuration
|
|
682
682
|
|
|
683
|
-
|
|
684
|
-
fields and the redaction rules are in the
|
|
685
|
-
[Logging guide](../../../../docs/guides/LOGGING.md). The encryption logger
|
|
686
|
-
still writes text lines through `console` with the same level names until it
|
|
687
|
-
moves to the structured logger (ADR-048, incremental adoption).
|
|
683
|
+
Configure log verbosity with `FRIGG_LOG_LEVEL`:
|
|
688
684
|
|
|
689
685
|
```bash
|
|
690
686
|
# Production (minimal logging)
|
|
@@ -818,6 +814,26 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
|
|
|
818
814
|
❌ Query on encrypted fields (not supported)
|
|
819
815
|
❌ Manually decrypt data (use extension)
|
|
820
816
|
|
|
817
|
+
The integration mapping repositories' `queryMappings()` (PostgreSQL, MongoDB
|
|
818
|
+
and DocumentDB) filters and sorts inside the `mapping` JSON, so it refuses to
|
|
819
|
+
run while field-level encryption still encrypts `IntegrationMapping.mapping` on
|
|
820
|
+
write. Opt the field out in the app definition, together with any nested
|
|
821
|
+
`mapping.*` path that `encryption.schema` encrypts; the Prisma extension and
|
|
822
|
+
`DocumentDBEncryptionService` both honor the opt-out:
|
|
823
|
+
|
|
824
|
+
```javascript
|
|
825
|
+
encryption: {
|
|
826
|
+
disable: { IntegrationMapping: ['mapping'] },
|
|
827
|
+
}
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
The opt-out applies to writes only. Rows written encrypted before it stay
|
|
831
|
+
readable, but `queryMappings()` does not match them until they are written
|
|
832
|
+
again. It returns the rows it matches decrypted, like
|
|
833
|
+
`findMappingsByIntegration()`: a nested `mapping.*` path written encrypted
|
|
834
|
+
before its opt-out reads plain, though conditions and ordering on that path
|
|
835
|
+
still see the ciphertext until the row is written again.
|
|
836
|
+
|
|
821
837
|
## Future Enhancements
|
|
822
838
|
|
|
823
839
|
### Planned
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const { logger } = require('./logger');
|
|
12
|
-
const { addDeniedKeys } = require('../../logs/redact');
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* Core encryption schema (immutable - cannot be overridden by custom schemas)
|
|
@@ -121,7 +120,6 @@ function registerCustomSchema(schema) {
|
|
|
121
120
|
}
|
|
122
121
|
|
|
123
122
|
customSchema = { ...schema };
|
|
124
|
-
addDeniedKeys(Object.values(schema).flatMap((config) => config.fields));
|
|
125
123
|
logger.info(
|
|
126
124
|
`Registered custom encryption schema for models: ${Object.keys(customSchema).join(', ')}`
|
|
127
125
|
);
|
|
@@ -151,7 +149,6 @@ function extractCredentialFieldsFromModules(moduleDefinitions) {
|
|
|
151
149
|
}
|
|
152
150
|
}
|
|
153
151
|
|
|
154
|
-
addDeniedKeys(fields);
|
|
155
152
|
return [...new Set(fields)];
|
|
156
153
|
}
|
|
157
154
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { getEncryptionConfig } = require('../prisma');
|
|
2
|
+
const { Cryptor } = require('../../encrypt/Cryptor');
|
|
3
|
+
const {
|
|
4
|
+
getFieldsToDecryptOnRead,
|
|
5
|
+
getFieldsToEncryptOnWrite,
|
|
6
|
+
loadCustomEncryptionSchema,
|
|
7
|
+
} = require('./encryption-schema-registry');
|
|
8
|
+
const {
|
|
9
|
+
createFieldEncryptionService,
|
|
10
|
+
} = require('./prisma-encryption-extension');
|
|
11
|
+
|
|
12
|
+
let plainMappingEncryption = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `IntegrationMapping` fields, `mapping` itself or a nested `mapping.*`
|
|
16
|
+
* path, that field-level encryption encrypts on write. An empty result is
|
|
17
|
+
* kept for the life of the process.
|
|
18
|
+
*
|
|
19
|
+
* @returns {string[]} Empty when every mapping path is written as plain JSON
|
|
20
|
+
*/
|
|
21
|
+
function getMappingFieldsEncryptedOnWrite() {
|
|
22
|
+
return mappingEncryption().encryptedOnWrite;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @throws {Error} When field-level encryption still encrypts `mapping`, or a
|
|
27
|
+
* nested `mapping.*` path, on write, naming the opt-out that lifts it
|
|
28
|
+
*/
|
|
29
|
+
function assertMappingWrittenUnencrypted() {
|
|
30
|
+
const fields = getMappingFieldsEncryptedOnWrite();
|
|
31
|
+
if (fields.length === 0) return;
|
|
32
|
+
|
|
33
|
+
const encrypted = fields
|
|
34
|
+
.map((field) => `IntegrationMapping.${field}`)
|
|
35
|
+
.join(', ');
|
|
36
|
+
const optOut = fields.map((field) => `'${field}'`).join(', ');
|
|
37
|
+
throw new Error(
|
|
38
|
+
`queryMappings: field-level encryption still encrypts ${encrypted} on write, so it cannot be queried. Opt out by adding ${optOut} to appDefinition.encryption.disable.IntegrationMapping.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decrypts rows that `queryMappings` read around the Prisma encryption
|
|
44
|
+
* extension, the way reads through the extension do: every
|
|
45
|
+
* `IntegrationMapping` field the schema lists, opted-out paths included, so a
|
|
46
|
+
* path written encrypted before its opt-out comes back plain.
|
|
47
|
+
*
|
|
48
|
+
* @param {Object[]} rows - Rows whose `mapping` is a JSON object
|
|
49
|
+
* @returns {Promise<Object[]>} `rows` itself when encryption is off or the
|
|
50
|
+
* schema lists no field besides `mapping`
|
|
51
|
+
*/
|
|
52
|
+
async function decryptQueriedMappings(rows) {
|
|
53
|
+
const { decryptor } = mappingEncryption();
|
|
54
|
+
if (!decryptor) return rows;
|
|
55
|
+
return decryptor.decryptFieldsInBulk('IntegrationMapping', rows);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mappingEncryption() {
|
|
59
|
+
if (plainMappingEncryption) return plainMappingEncryption;
|
|
60
|
+
|
|
61
|
+
const encryption = currentMappingEncryption();
|
|
62
|
+
if (encryption.encryptedOnWrite.length === 0) {
|
|
63
|
+
plainMappingEncryption = encryption;
|
|
64
|
+
}
|
|
65
|
+
return encryption;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function currentMappingEncryption() {
|
|
69
|
+
const config = getEncryptionConfig();
|
|
70
|
+
if (!config.enabled) return { encryptedOnWrite: [], decryptor: null };
|
|
71
|
+
|
|
72
|
+
loadCustomEncryptionSchema();
|
|
73
|
+
const encryptedOnWrite = getFieldsToEncryptOnWrite(
|
|
74
|
+
'IntegrationMapping'
|
|
75
|
+
).filter((field) => field === 'mapping' || field.startsWith('mapping.'));
|
|
76
|
+
const decryptsBesidesMapping = getFieldsToDecryptOnRead(
|
|
77
|
+
'IntegrationMapping'
|
|
78
|
+
).some((field) => field !== 'mapping');
|
|
79
|
+
const decryptor = decryptsBesidesMapping
|
|
80
|
+
? createFieldEncryptionService(
|
|
81
|
+
new Cryptor({ shouldUseAws: config.method === 'kms' })
|
|
82
|
+
)
|
|
83
|
+
: null;
|
|
84
|
+
return { encryptedOnWrite, decryptor };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Test helper: forget a kept result. */
|
|
88
|
+
function resetMappingEncryptionCheck() {
|
|
89
|
+
plainMappingEncryption = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
assertMappingWrittenUnencrypted,
|
|
94
|
+
decryptQueriedMappings,
|
|
95
|
+
getMappingFieldsEncryptedOnWrite,
|
|
96
|
+
resetMappingEncryptionCheck,
|
|
97
|
+
};
|
|
@@ -10,6 +10,24 @@ const {
|
|
|
10
10
|
} = require('./encryption-schema-registry');
|
|
11
11
|
const { FieldEncryptionService } = require('./field-encryption-service');
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The FieldEncryptionService the extension runs, with field paths from the
|
|
15
|
+
* encryption schema registry.
|
|
16
|
+
*
|
|
17
|
+
* @param {import('../../encrypt/Cryptor').Cryptor} cryptor
|
|
18
|
+
* @returns {FieldEncryptionService}
|
|
19
|
+
*/
|
|
20
|
+
function createFieldEncryptionService(cryptor) {
|
|
21
|
+
return new FieldEncryptionService({
|
|
22
|
+
cryptor,
|
|
23
|
+
schema: {
|
|
24
|
+
getEncryptedFields,
|
|
25
|
+
getFieldsToEncryptOnWrite,
|
|
26
|
+
getFieldsToDecryptOnRead,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
14
32
|
if (!enabled) {
|
|
15
33
|
return (client) => client;
|
|
@@ -21,14 +39,7 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
|
21
39
|
);
|
|
22
40
|
}
|
|
23
41
|
|
|
24
|
-
const encryptionService =
|
|
25
|
-
cryptor,
|
|
26
|
-
schema: {
|
|
27
|
-
getEncryptedFields,
|
|
28
|
-
getFieldsToEncryptOnWrite,
|
|
29
|
-
getFieldsToDecryptOnRead,
|
|
30
|
-
},
|
|
31
|
-
});
|
|
42
|
+
const encryptionService = createFieldEncryptionService(cryptor);
|
|
32
43
|
|
|
33
44
|
return {
|
|
34
45
|
name: 'frigg-field-encryption',
|
|
@@ -227,4 +238,4 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
|
227
238
|
};
|
|
228
239
|
}
|
|
229
240
|
|
|
230
|
-
module.exports = { createEncryptionExtension };
|
|
241
|
+
module.exports = { createEncryptionExtension, createFieldEncryptionService };
|