@friggframework/core 2.0.0--canary.710.7cfcf12.0 → 2.0.0--canary.682.451c957.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/CLAUDE.md +3 -41
- package/README.md +105 -0
- package/application/commands/README.md +11 -17
- package/application/commands/entity-commands.js +60 -18
- package/application/commands/integration-commands.js +20 -73
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +117 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +66 -0
- package/database/documentdb-encryption-service.js +33 -2
- package/database/documentdb-utils.js +2 -0
- package/database/encryption/README.md +3 -3
- package/database/encryption/encryption-schema-registry.js +42 -109
- package/database/encryption/field-encryption-service.js +16 -13
- package/database/organization-where.js +18 -0
- package/database/prisma.js +1 -10
- package/database/unique-violation.js +25 -0
- package/errors/index.js +0 -4
- package/generated/prisma-mongodb/edge.js +31 -4
- package/generated/prisma-mongodb/index-browser.js +28 -1
- package/generated/prisma-mongodb/index.d.ts +3451 -62
- package/generated/prisma-mongodb/index.js +31 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +74 -0
- package/generated/prisma-mongodb/wasm.js +31 -4
- package/generated/prisma-postgresql/edge.js +31 -4
- package/generated/prisma-postgresql/index-browser.js +28 -1
- package/generated/prisma-postgresql/index.d.ts +3633 -53
- package/generated/prisma-postgresql/index.js +31 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +70 -0
- package/generated/prisma-postgresql/wasm.js +31 -4
- package/handlers/WEBHOOKS.md +0 -29
- package/handlers/app-definition-loader.js +33 -8
- package/handlers/app-handler-helpers.js +25 -3
- package/handlers/backend-utils.js +31 -25
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +11 -3
- package/handlers/routers/health.js +11 -4
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +23 -2
- package/infrastructure/scheduler/eventbridge-scheduler-adapter.js +13 -67
- package/integrations/index.js +1 -0
- package/integrations/integration-base.js +0 -29
- package/integrations/integration-router.js +136 -98
- package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
- package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
- package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
- package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
- package/integrations/repositories/integration-mapping-repository.js +0 -29
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -35
- package/integrations/repositories/integration-repository-interface.js +17 -31
- package/integrations/repositories/integration-repository-mongo.js +60 -129
- package/integrations/repositories/integration-repository-postgres.js +62 -134
- package/integrations/repositories/process-repository-mongo.js +1 -1
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +72 -75
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +5 -1
- package/integrations/use-cases/integration-operations-for-user.js +246 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +47 -15
- package/jest-setup.js +2 -13
- package/jest-teardown.js +2 -5
- package/jest.config.js +0 -4
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +85 -0
- package/management-api/meta-router.js +57 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +407 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +261 -0
- package/management-api/route-registry.js +294 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +279 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +29 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/proxy/proxy-errors.js +20 -0
- package/modules/proxy/proxy-path.js +179 -0
- package/modules/proxy/proxy-policy.js +248 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/module-repository-documentdb.js +48 -4
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +65 -73
- package/modules/repositories/module-repository-postgres.js +64 -73
- package/modules/requester/proxy-request.js +244 -0
- package/modules/requester/requester.js +27 -2
- package/modules/scope-config.js +155 -0
- package/modules/scope-policy.js +80 -0
- package/modules/scope-resolution.js +277 -0
- package/modules/scope-visibility.js +165 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +59 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +157 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +254 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +16 -5
- package/modules/use-cases/get-entity-module-for-user.js +106 -0
- package/modules/use-cases/get-entity-options-by-id.js +16 -8
- package/modules/use-cases/get-module.js +27 -11
- package/modules/use-cases/multi-step-authorization.js +326 -0
- package/modules/use-cases/process-authorization-callback.js +52 -18
- package/modules/use-cases/refresh-entity-options.js +16 -8
- package/modules/use-cases/test-module-auth.js +16 -8
- package/modules/utils/find-module-definition.js +23 -0
- package/modules/utils/map-module-dto.js +10 -6
- package/modules/utils/public-entity.js +59 -0
- package/package.json +6 -7
- package/prisma-mongodb/schema.prisma +74 -0
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/schema.prisma +70 -0
- package/queues/queuer-util.js +5 -6
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +155 -0
- package/types/user/index.d.ts +117 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/tests/doubles/test-user-repository.js +1 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +86 -125
- package/user/use-cases/get-user-from-bearer-token.js +2 -7
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +27 -0
- package/database/encryption/ciphertext-format.js +0 -24
- package/errors/mapping-already-exists-error.js +0 -14
- package/integrations/use-cases/add-entities-to-integration.js +0 -92
- package/integrations/use-cases/remove-entities-from-integration.js +0 -70
- package/integrations/utils/assert-entity-ids-input.js +0 -42
- package/jest.db.config.js +0 -23
- package/modules/utils/to-credential-id.js +0 -14
|
@@ -188,6 +188,29 @@ class CredentialRepositoryMongo extends CredentialRepositoryInterface {
|
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* All credentials owned by a user, newest first.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} userId - User ID
|
|
195
|
+
* @returns {Promise<Array<Object>>} Credentials with string IDs
|
|
196
|
+
*/
|
|
197
|
+
async findCredentialsByUserId(userId) {
|
|
198
|
+
if (!userId) return [];
|
|
199
|
+
const credentials = await this.prisma.credential.findMany({
|
|
200
|
+
where: { userId: String(userId) },
|
|
201
|
+
orderBy: { createdAt: 'desc' },
|
|
202
|
+
});
|
|
203
|
+
return credentials.map((credential) => ({
|
|
204
|
+
...(credential.data || {}),
|
|
205
|
+
id: credential.id,
|
|
206
|
+
userId: credential.userId,
|
|
207
|
+
externalId: credential.externalId,
|
|
208
|
+
authIsValid: credential.authIsValid,
|
|
209
|
+
createdAt: credential.createdAt,
|
|
210
|
+
updatedAt: credential.updatedAt,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
|
|
191
214
|
/**
|
|
192
215
|
* Update a credential by ID
|
|
193
216
|
* Replaces: Credential.findByIdAndUpdate(credentialId, { $set: updates })
|
|
@@ -205,6 +205,34 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
|
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* All credentials owned by a user, newest first.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} userId - User ID (string from application layer)
|
|
212
|
+
* @returns {Promise<Array<Object>>} Credentials with string IDs
|
|
213
|
+
*/
|
|
214
|
+
async findCredentialsByUserId(userId) {
|
|
215
|
+
let intUserId;
|
|
216
|
+
try {
|
|
217
|
+
intUserId = this._convertId(userId);
|
|
218
|
+
} catch {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
const credentials = await this.prisma.credential.findMany({
|
|
222
|
+
where: { userId: intUserId },
|
|
223
|
+
orderBy: { createdAt: 'desc' },
|
|
224
|
+
});
|
|
225
|
+
return credentials.map((credential) => ({
|
|
226
|
+
...(credential.data || {}),
|
|
227
|
+
id: credential.id.toString(),
|
|
228
|
+
userId: credential.userId?.toString(),
|
|
229
|
+
externalId: credential.externalId,
|
|
230
|
+
authIsValid: credential.authIsValid,
|
|
231
|
+
createdAt: credential.createdAt,
|
|
232
|
+
updatedAt: credential.updatedAt,
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
|
|
208
236
|
/**
|
|
209
237
|
* Update a credential by ID
|
|
210
238
|
*
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Management API v2 credential operations. A credential has no module name
|
|
6
|
+
* of its own; its `type` and the entities that use it come from the
|
|
7
|
+
* caller's entities. A missing or foreign credential is a 404.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const credentialIdOf = (entity) => {
|
|
11
|
+
const ref = entity.credential ?? entity.credentialId;
|
|
12
|
+
if (ref === undefined || ref === null) return null;
|
|
13
|
+
if (typeof ref === 'object') {
|
|
14
|
+
const id = ref.id ?? ref._id;
|
|
15
|
+
return id === undefined || id === null ? null : String(id);
|
|
16
|
+
}
|
|
17
|
+
return String(ref);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Adds `type` and `entityIds` from the user's entities to each credential. */
|
|
21
|
+
function withEntityUsage(credentials, entities) {
|
|
22
|
+
const usage = new Map();
|
|
23
|
+
for (const entity of entities || []) {
|
|
24
|
+
const credentialId = credentialIdOf(entity);
|
|
25
|
+
if (!credentialId) continue;
|
|
26
|
+
const entry = usage.get(credentialId) || { type: null, entityIds: [] };
|
|
27
|
+
entry.type = entry.type || entity.moduleName || null;
|
|
28
|
+
entry.entityIds.push(String(entity.id));
|
|
29
|
+
usage.set(credentialId, entry);
|
|
30
|
+
}
|
|
31
|
+
return credentials.map((credential) => {
|
|
32
|
+
const entry = usage.get(String(credential.id)) || {
|
|
33
|
+
type: null,
|
|
34
|
+
entityIds: [],
|
|
35
|
+
};
|
|
36
|
+
return { ...credential, type: entry.type, entityIds: entry.entityIds };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The caller's credentials with their type and entity usage. */
|
|
41
|
+
class ListCredentialsForUser {
|
|
42
|
+
constructor({ credentialRepository, moduleRepository }) {
|
|
43
|
+
this.credentialRepository = credentialRepository;
|
|
44
|
+
this.moduleRepository = moduleRepository;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async execute(user) {
|
|
48
|
+
const userId = user.getId();
|
|
49
|
+
const [credentials, entities] = await Promise.all([
|
|
50
|
+
this.credentialRepository.findCredentialsByUserId(userId),
|
|
51
|
+
this.moduleRepository.findEntitiesByUserId(userId),
|
|
52
|
+
]);
|
|
53
|
+
return withEntityUsage(credentials || [], entities);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One credential the caller owns, with its type and entity usage. */
|
|
58
|
+
class GetCredentialForUserV2 {
|
|
59
|
+
constructor({ credentialRepository, moduleRepository }) {
|
|
60
|
+
this.credentialRepository = credentialRepository;
|
|
61
|
+
this.moduleRepository = moduleRepository;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async execute(credentialId, user) {
|
|
65
|
+
let credential = null;
|
|
66
|
+
try {
|
|
67
|
+
credential = await this.credentialRepository.findCredentialById(
|
|
68
|
+
credentialId
|
|
69
|
+
);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!credential || !userOwns(user, credential.userId)) {
|
|
76
|
+
throw Boom.notFound(`Credential ${credentialId} not found`, {
|
|
77
|
+
code: 'CREDENTIAL_NOT_FOUND',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const entities = await this.moduleRepository.findEntitiesByUserId(
|
|
81
|
+
credential.userId
|
|
82
|
+
);
|
|
83
|
+
return withEntityUsage([credential], entities)[0];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Deletes a credential the caller owns. The entities that used it stay, with
|
|
89
|
+
* their credential unset, so they show as needing re-authorization rather
|
|
90
|
+
* than disappearing from integrations.
|
|
91
|
+
*/
|
|
92
|
+
class DeleteCredentialForUser {
|
|
93
|
+
constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
|
|
94
|
+
this.credentialRepository = credentialRepository;
|
|
95
|
+
this.moduleRepository = moduleRepository;
|
|
96
|
+
this.getCredentialForUser = getCredentialForUser;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async execute(credentialId, user) {
|
|
100
|
+
const credential = await this.getCredentialForUser.execute(
|
|
101
|
+
credentialId,
|
|
102
|
+
user
|
|
103
|
+
);
|
|
104
|
+
for (const entityId of credential.entityIds) {
|
|
105
|
+
await this.moduleRepository.unsetCredential(entityId);
|
|
106
|
+
}
|
|
107
|
+
await this.credentialRepository.deleteCredentialById(credential.id);
|
|
108
|
+
return { id: String(credential.id), entityIds: credential.entityIds };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
ListCredentialsForUser,
|
|
114
|
+
GetCredentialForUserV2,
|
|
115
|
+
DeleteCredentialForUser,
|
|
116
|
+
withEntityUsage,
|
|
117
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Re-authorization of a credential the caller owns (Management API v2). It
|
|
5
|
+
* runs the same flow as /api/v2/authorize for the credential's entity type,
|
|
6
|
+
* with the session bound to the credential so it cannot be reused for
|
|
7
|
+
* anything else.
|
|
8
|
+
*/
|
|
9
|
+
async function credentialEntityType(getCredentialForUser, credentialId, user) {
|
|
10
|
+
const credential = await getCredentialForUser.execute(credentialId, user);
|
|
11
|
+
if (!credential.type) {
|
|
12
|
+
throw Boom.conflict(
|
|
13
|
+
`Credential ${credentialId} is not used by any entity, so its type is unknown; authorize a new connection instead`,
|
|
14
|
+
{ code: 'CREDENTIAL_TYPE_UNKNOWN' }
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return { entityType: credential.type, credentialId: String(credential.id) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** GET /api/v2/credentials/:credentialId/reauthorize */
|
|
21
|
+
class GetCredentialReauthorization {
|
|
22
|
+
constructor({ getCredentialForUser, getAuthorizationStep }) {
|
|
23
|
+
this.getCredentialForUser = getCredentialForUser;
|
|
24
|
+
this.getAuthorizationStep = getAuthorizationStep;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
|
|
28
|
+
const binding = await credentialEntityType(
|
|
29
|
+
this.getCredentialForUser,
|
|
30
|
+
credentialId,
|
|
31
|
+
user
|
|
32
|
+
);
|
|
33
|
+
return this.getAuthorizationStep.execute({
|
|
34
|
+
user,
|
|
35
|
+
...binding,
|
|
36
|
+
step,
|
|
37
|
+
sessionId,
|
|
38
|
+
state,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** POST /api/v2/credentials/:credentialId/reauthorize */
|
|
44
|
+
class ReauthorizeCredentialForUser {
|
|
45
|
+
constructor({ getCredentialForUser, submitAuthorizationStep }) {
|
|
46
|
+
this.getCredentialForUser = getCredentialForUser;
|
|
47
|
+
this.submitAuthorizationStep = submitAuthorizationStep;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
|
|
51
|
+
const binding = await credentialEntityType(
|
|
52
|
+
this.getCredentialForUser,
|
|
53
|
+
credentialId,
|
|
54
|
+
user
|
|
55
|
+
);
|
|
56
|
+
return this.submitAuthorizationStep.execute({
|
|
57
|
+
user,
|
|
58
|
+
...binding,
|
|
59
|
+
data,
|
|
60
|
+
step,
|
|
61
|
+
sessionId,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { GetCredentialReauthorization, ReauthorizeCredentialForUser };
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
2
|
-
const { isEncryptedEnvelope } = require('./encryption/ciphertext-format');
|
|
3
2
|
const {
|
|
4
3
|
getEncryptedFields,
|
|
5
4
|
getFieldsToEncryptOnWrite,
|
|
@@ -296,7 +295,39 @@ class DocumentDBEncryptionService {
|
|
|
296
295
|
* _isEncryptedValue({}) // false
|
|
297
296
|
*/
|
|
298
297
|
_isEncryptedValue(value) {
|
|
299
|
-
|
|
298
|
+
// Must be string
|
|
299
|
+
if (typeof value !== 'string') {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Must have exactly 4 colon-separated parts
|
|
304
|
+
const parts = value.split(':');
|
|
305
|
+
if (parts.length !== 4) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Enhanced validation: check for base64 pattern
|
|
310
|
+
// This prevents false positives on URLs, connection strings, etc.
|
|
311
|
+
const base64Pattern = /^[A-Za-z0-9+/=]+$/;
|
|
312
|
+
|
|
313
|
+
// All parts should be base64-encoded
|
|
314
|
+
if (!parts.every(part => base64Pattern.test(part))) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Encrypted values should be sufficiently long to be valid
|
|
319
|
+
// Real encrypted values from Cryptor are always >50 chars due to envelope encryption format:
|
|
320
|
+
// - keyId (base64): ~12 chars minimum
|
|
321
|
+
// - iv (base64): ~24 chars for 16-byte IV
|
|
322
|
+
// - ciphertext (base64): varies, minimum ~16 chars for small values
|
|
323
|
+
// - encryptedKey (base64): ~44 chars for 32-byte data key
|
|
324
|
+
// Total minimum: ~96 chars, so 50 is a safe lower bound
|
|
325
|
+
// This prevents false positives on non-encrypted strings that happen to have 4 colons
|
|
326
|
+
if (value.length < 50) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return true;
|
|
300
331
|
}
|
|
301
332
|
}
|
|
302
333
|
|
|
@@ -58,6 +58,8 @@ async function insertOne(client, collection, document) {
|
|
|
58
58
|
|
|
59
59
|
// Provide helpful context for common errors
|
|
60
60
|
if (error.code === 11000) {
|
|
61
|
+
// `code` lets callers recognise a lost race without parsing text
|
|
62
|
+
// (see database/unique-violation.js).
|
|
61
63
|
throw Object.assign(
|
|
62
64
|
new Error(`${errorMsg} - Duplicate key violation`),
|
|
63
65
|
{ code: 11000 }
|
|
@@ -508,9 +508,9 @@ return entities.map((e) => ({
|
|
|
508
508
|
|
|
509
509
|
**Verified:**
|
|
510
510
|
|
|
511
|
-
- ✅ `postgres-relation-decryption.
|
|
512
|
-
- ✅ `postgres-decryption-fix-verification.
|
|
513
|
-
- ✅ `mongo-decryption-fix-verification.
|
|
511
|
+
- ✅ `postgres-relation-decryption.test.js` - Proves the bug exists
|
|
512
|
+
- ✅ `postgres-decryption-fix-verification.test.js` - Verifies separate queries work
|
|
513
|
+
- ✅ `mongo-decryption-fix-verification.test.js` - Verifies fix for MongoDB
|
|
514
514
|
|
|
515
515
|
**Implementation Examples:**
|
|
516
516
|
|
|
@@ -39,6 +39,12 @@ const CORE_ENCRYPTION_SCHEMA = {
|
|
|
39
39
|
Token: {
|
|
40
40
|
fields: ['token'],
|
|
41
41
|
},
|
|
42
|
+
|
|
43
|
+
// Multi-step authorization (Management API v2): earlier steps can collect
|
|
44
|
+
// emails, usernames or one-time codes.
|
|
45
|
+
AuthorizationSession: {
|
|
46
|
+
fields: ['stepData'],
|
|
47
|
+
},
|
|
42
48
|
};
|
|
43
49
|
|
|
44
50
|
let customSchema = {};
|
|
@@ -146,16 +152,6 @@ function extractCredentialFieldsFromModules(moduleDefinitions) {
|
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
for (const field of credentialFields) {
|
|
149
|
-
// Validate entry by entry: one bad entry must not drop the
|
|
150
|
-
// module's (or any other module's) valid fields.
|
|
151
|
-
if (typeof field !== 'string' || field.trim() === '') {
|
|
152
|
-
logger.warn(
|
|
153
|
-
`Ignoring invalid encryption.credentialFields entry ${JSON.stringify(field)} ` +
|
|
154
|
-
`in module "${moduleDef.moduleName || moduleDef.name || 'unknown'}" ` +
|
|
155
|
-
'(expected a non-empty string)'
|
|
156
|
-
);
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
155
|
const prefixedField = field.startsWith('data.') ? field : `data.${field}`;
|
|
160
156
|
fields.push(prefixedField);
|
|
161
157
|
}
|
|
@@ -184,46 +180,33 @@ function loadModuleEncryptionSchemas(integrations) {
|
|
|
184
180
|
return;
|
|
185
181
|
}
|
|
186
182
|
|
|
187
|
-
|
|
183
|
+
// NOTE: this path does not exist ('../integrations' resolves to
|
|
184
|
+
// database/integrations), so today this function throws MODULE_NOT_FOUND,
|
|
185
|
+
// loadCustomEncryptionSchema swallows it, and module credentialFields are
|
|
186
|
+
// effectively NOT registered. Fixing it changes which fields are encrypted
|
|
187
|
+
// and needs a decrypt that tolerates plaintext, so it ships separately:
|
|
188
|
+
// branch claude/internal-mcp-03b-module-credential-encryption. Left as is
|
|
189
|
+
// here on purpose (ADR-059 must not change what gets encrypted).
|
|
190
|
+
const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
|
|
188
191
|
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
(integration) => integration?.Definition?.modules
|
|
192
|
-
);
|
|
193
|
-
const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(
|
|
194
|
-
integrationsWithModules
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
// Fields the core schema already encrypts (e.g. data.api_key) are
|
|
198
|
-
// covered; registering them again would fail validation and drop every
|
|
199
|
-
// other module field with them.
|
|
200
|
-
const coreCredentialFields = CORE_ENCRYPTION_SCHEMA.Credential.fields;
|
|
201
|
-
const credentialFields = extractCredentialFieldsFromModules(
|
|
202
|
-
moduleDefinitions
|
|
203
|
-
).filter((field) => !coreCredentialFields.includes(field));
|
|
192
|
+
const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations);
|
|
193
|
+
const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions);
|
|
204
194
|
|
|
205
195
|
if (credentialFields.length === 0) {
|
|
206
196
|
return;
|
|
207
197
|
}
|
|
208
198
|
|
|
209
|
-
|
|
210
|
-
// than replacing it (registerCustomSchema overwrites).
|
|
211
|
-
const existingCredentialFields = customSchema.Credential?.fields || [];
|
|
212
|
-
const mergedSchema = {
|
|
213
|
-
...customSchema,
|
|
199
|
+
const moduleSchema = {
|
|
214
200
|
Credential: {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
...new Set([...existingCredentialFields, ...credentialFields]),
|
|
218
|
-
],
|
|
219
|
-
},
|
|
201
|
+
fields: credentialFields
|
|
202
|
+
}
|
|
220
203
|
};
|
|
221
204
|
|
|
222
205
|
logger.info(
|
|
223
206
|
`Registering module-level encryption for ${credentialFields.length} credential fields`
|
|
224
207
|
);
|
|
225
208
|
|
|
226
|
-
registerCustomSchema(
|
|
209
|
+
registerCustomSchema(moduleSchema);
|
|
227
210
|
}
|
|
228
211
|
|
|
229
212
|
/**
|
|
@@ -235,40 +218,7 @@ function loadModuleEncryptionSchemas(integrations) {
|
|
|
235
218
|
*
|
|
236
219
|
* Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services.
|
|
237
220
|
*/
|
|
238
|
-
/**
|
|
239
|
-
* Raised when an app's encryption configuration is invalid. In deployed
|
|
240
|
-
* stages this fails startup instead of silently writing plaintext.
|
|
241
|
-
*/
|
|
242
|
-
class EncryptionSchemaError extends Error {
|
|
243
|
-
constructor(message, { cause } = {}) {
|
|
244
|
-
super(message, { cause });
|
|
245
|
-
this.name = 'EncryptionSchemaError';
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Stages where field-level encryption is bypassed (see prisma.js). */
|
|
250
|
-
function isLocalStage() {
|
|
251
|
-
const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
|
|
252
|
-
return ['dev', 'test', 'local'].includes(stage.toLowerCase());
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* An app definition was found but part of its encryption configuration
|
|
257
|
-
* could not be applied. Deployed stages fail closed; local stages warn and
|
|
258
|
-
* carry on so development isn't blocked.
|
|
259
|
-
*/
|
|
260
|
-
function reportInvalidEncryptionConfig(what, error) {
|
|
261
|
-
const message = `Invalid ${what}: ${error.message}`;
|
|
262
|
-
if (isLocalStage()) {
|
|
263
|
-
logger.warn(`${message} (ignored in local stage)`);
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
logger.error(message);
|
|
267
|
-
throw new EncryptionSchemaError(message, { cause: error });
|
|
268
|
-
}
|
|
269
|
-
|
|
270
221
|
function loadCustomEncryptionSchema() {
|
|
271
|
-
let appDefinition;
|
|
272
222
|
try {
|
|
273
223
|
// Lazy require to avoid circular dependency issues
|
|
274
224
|
const path = require('node:path');
|
|
@@ -283,54 +233,38 @@ function loadCustomEncryptionSchema() {
|
|
|
283
233
|
const backendIndexPath = path.join(backendDir, 'index.js');
|
|
284
234
|
|
|
285
235
|
const backendModule = require(backendIndexPath);
|
|
286
|
-
appDefinition = backendModule?.Definition;
|
|
287
|
-
} catch (error) {
|
|
288
|
-
// No loadable backend (tests, standalone usage): custom schema is
|
|
289
|
-
// optional, so this stays at debug level.
|
|
290
|
-
logger.debug('Could not load custom encryption schema:', error.message);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
236
|
+
const appDefinition = backendModule?.Definition;
|
|
293
237
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// From here an app definition exists, so failures are configuration
|
|
299
|
-
// errors and must be visible.
|
|
238
|
+
if (!appDefinition) {
|
|
239
|
+
return; // No app definition found
|
|
240
|
+
}
|
|
300
241
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
try {
|
|
242
|
+
// Load app-level custom schema
|
|
243
|
+
const customSchema = appDefinition.encryption?.schema;
|
|
244
|
+
if (customSchema && Object.keys(customSchema).length > 0) {
|
|
305
245
|
registerCustomSchema(customSchema);
|
|
306
|
-
} catch (error) {
|
|
307
|
-
reportInvalidEncryptionConfig('app encryption.schema', error);
|
|
308
246
|
}
|
|
309
|
-
}
|
|
310
247
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
try {
|
|
248
|
+
// Load app-level encryption opt-out — apps can declare fields they
|
|
249
|
+
// don't want encrypted on write (decryption on read still works,
|
|
250
|
+
// so legacy data remains readable).
|
|
251
|
+
const disable = appDefinition.encryption?.disable;
|
|
252
|
+
if (disable && Object.keys(disable).length > 0) {
|
|
317
253
|
registerEncryptionOptOut(disable);
|
|
318
|
-
} catch (error) {
|
|
319
|
-
reportInvalidEncryptionConfig('app encryption.disable', error);
|
|
320
254
|
}
|
|
321
|
-
}
|
|
322
255
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
try {
|
|
256
|
+
// Load module-level encryption schemas from integrations
|
|
257
|
+
const integrations = appDefinition.integrations;
|
|
258
|
+
if (integrations && Array.isArray(integrations)) {
|
|
327
259
|
loadModuleEncryptionSchemas(integrations);
|
|
328
|
-
} catch (error) {
|
|
329
|
-
reportInvalidEncryptionConfig(
|
|
330
|
-
'module encryption.credentialFields',
|
|
331
|
-
error
|
|
332
|
-
);
|
|
333
260
|
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// Silently ignore errors - custom schema is optional
|
|
263
|
+
// This handles cases like:
|
|
264
|
+
// - Backend package.json not found (tests, standalone usage)
|
|
265
|
+
// - No appDefinition defined
|
|
266
|
+
// - No custom encryption schema specified
|
|
267
|
+
logger.debug('Could not load custom encryption schema:', error.message);
|
|
334
268
|
}
|
|
335
269
|
}
|
|
336
270
|
|
|
@@ -465,7 +399,6 @@ function resetCustomSchema() {
|
|
|
465
399
|
}
|
|
466
400
|
|
|
467
401
|
module.exports = {
|
|
468
|
-
EncryptionSchemaError,
|
|
469
402
|
CORE_ENCRYPTION_SCHEMA,
|
|
470
403
|
getEncryptedFields,
|
|
471
404
|
getFieldsToEncryptOnWrite,
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
const { isEncryptedEnvelope } = require('./ciphertext-format');
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Field Encryption Service
|
|
5
3
|
*
|
|
@@ -149,7 +147,12 @@ class FieldEncryptionService {
|
|
|
149
147
|
}
|
|
150
148
|
|
|
151
149
|
_isEncrypted(value) {
|
|
152
|
-
|
|
150
|
+
if (typeof value !== 'string') {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const parts = value.split(':');
|
|
155
|
+
return parts.length >= 4;
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
_getNestedValue(obj, path) {
|
|
@@ -215,22 +218,22 @@ class FieldEncryptionService {
|
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
/**
|
|
218
|
-
* Serialize a value for encryption
|
|
219
|
-
*
|
|
220
|
-
* the round trip: '123456' is stored as "\"123456\"" and reads back as a
|
|
221
|
-
* string, 1234 reads back as a number.
|
|
221
|
+
* Serialize a value for encryption
|
|
222
|
+
* Objects/arrays are JSON stringified, primitives are converted to strings
|
|
222
223
|
* @private
|
|
223
224
|
*/
|
|
224
225
|
_serializeForEncryption(value) {
|
|
225
|
-
|
|
226
|
+
if (typeof value === 'object' && value !== null) {
|
|
227
|
+
// JSON.stringify for objects and arrays
|
|
228
|
+
return JSON.stringify(value);
|
|
229
|
+
}
|
|
230
|
+
// For primitives (string, number, boolean), convert to string
|
|
231
|
+
return String(value);
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
/**
|
|
229
|
-
* Deserialize a value after decryption
|
|
230
|
-
*
|
|
231
|
-
* earlier versions stored strings raw: those that aren't valid JSON come
|
|
232
|
-
* back unchanged; a raw legacy string that is also valid JSON (e.g.
|
|
233
|
-
* '123456') still parses as it always did.
|
|
235
|
+
* Deserialize a value after decryption
|
|
236
|
+
* Attempts to parse as JSON, returns string if parsing fails
|
|
234
237
|
* @private
|
|
235
238
|
*/
|
|
236
239
|
_deserializeAfterDecryption(value) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Prisma `where` fragment for an `organizationId` on MongoDB (ADR-056
|
|
3
|
+
* §3.6), shared by the entity and integration adapters.
|
|
4
|
+
*
|
|
5
|
+
* Prisma on MongoDB: `null` matches only an explicit null and `isSet: false`
|
|
6
|
+
* only a missing field. A row written before the column existed is the common
|
|
7
|
+
* "no organization" case, so `null` here means both.
|
|
8
|
+
*/
|
|
9
|
+
function organizationWhere(organizationId) {
|
|
10
|
+
if (organizationId === null) {
|
|
11
|
+
return {
|
|
12
|
+
OR: [{ organizationId: null }, { organizationId: { isSet: false } }],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
return { organizationId };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { organizationWhere };
|
package/database/prisma.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
const {
|
|
2
2
|
createEncryptionExtension,
|
|
3
3
|
} = require('./encryption/prisma-encryption-extension');
|
|
4
|
-
const {
|
|
5
|
-
loadCustomEncryptionSchema,
|
|
6
|
-
EncryptionSchemaError,
|
|
7
|
-
} = require('./encryption/encryption-schema-registry');
|
|
4
|
+
const { loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry');
|
|
8
5
|
const { logger } = require('./encryption/logger');
|
|
9
6
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
10
7
|
const config = require('./config');
|
|
@@ -128,12 +125,6 @@ const prismaClientSingleton = () => {
|
|
|
128
125
|
`Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}`
|
|
129
126
|
);
|
|
130
127
|
} catch (error) {
|
|
131
|
-
// An invalid encryption configuration must fail startup: carrying
|
|
132
|
-
// on without the extension would write encrypted fields in
|
|
133
|
-
// plaintext.
|
|
134
|
-
if (error instanceof EncryptionSchemaError) {
|
|
135
|
-
throw error;
|
|
136
|
-
}
|
|
137
128
|
logger.error(
|
|
138
129
|
'Failed to initialize encryption extension:',
|
|
139
130
|
error
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Mongo/DocumentDB duplicate-key error code. */
|
|
2
|
+
const DUPLICATE_KEY_CODE = 11000;
|
|
3
|
+
/** Prisma's unique-constraint error code (PostgreSQL and MongoDB). */
|
|
4
|
+
const PRISMA_UNIQUE_CODE = 'P2002';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether `error` is a unique-constraint violation, however the database
|
|
8
|
+
* layer raised it: a Prisma `P2002`, or a duplicate-key `11000`
|
|
9
|
+
* (`documentdb-utils` `insertOne` sets `code` on the error it throws).
|
|
10
|
+
* Matches on the code only, never on message text.
|
|
11
|
+
*
|
|
12
|
+
* @param {unknown} error
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function isUniqueViolation(error) {
|
|
16
|
+
return (
|
|
17
|
+
error?.code === PRISMA_UNIQUE_CODE || error?.code === DUPLICATE_KEY_CODE
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
isUniqueViolation,
|
|
23
|
+
DUPLICATE_KEY_CODE,
|
|
24
|
+
PRISMA_UNIQUE_CODE,
|
|
25
|
+
};
|
package/errors/index.js
CHANGED
|
@@ -6,9 +6,6 @@ const {
|
|
|
6
6
|
ParameterTypeError,
|
|
7
7
|
} = require('./validation-errors');
|
|
8
8
|
const { ClientSafeError } = require('./client-safe-error');
|
|
9
|
-
const {
|
|
10
|
-
MappingAlreadyExistsError,
|
|
11
|
-
} = require('./mapping-already-exists-error');
|
|
12
9
|
|
|
13
10
|
module.exports = {
|
|
14
11
|
BaseError,
|
|
@@ -17,5 +14,4 @@ module.exports = {
|
|
|
17
14
|
RequiredPropertyError,
|
|
18
15
|
ParameterTypeError,
|
|
19
16
|
ClientSafeError,
|
|
20
|
-
MappingAlreadyExistsError,
|
|
21
17
|
};
|