@friggframework/core 2.0.0--canary.680.b3ca7f1.0 → 2.0.0--canary.681.d970b9e.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/CHANGELOG.md +0 -11
- package/CLAUDE.md +2 -0
- package/README.md +69 -0
- package/application/commands/integration-commands.js +8 -18
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +2 -6
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +0 -2
- package/database/encryption/encryption-schema-registry.js +12 -35
- package/database/encryption/field-encryption-service.js +3 -30
- package/database/unique-violation.js +25 -0
- package/generated/prisma-mongodb/edge.js +13 -4
- package/generated/prisma-mongodb/index-browser.js +10 -1
- package/generated/prisma-mongodb/index.d.ts +1922 -94
- package/generated/prisma-mongodb/index.js +13 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +32 -0
- package/generated/prisma-mongodb/wasm.js +13 -4
- package/generated/prisma-postgresql/edge.js +13 -4
- package/generated/prisma-postgresql/index-browser.js +10 -1
- package/generated/prisma-postgresql/index.d.ts +2053 -83
- package/generated/prisma-postgresql/index.js +13 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +29 -0
- package/generated/prisma-postgresql/wasm.js +13 -4
- package/handlers/app-definition-loader.js +13 -16
- package/handlers/backend-utils.js +25 -31
- package/handlers/routers/health.js +4 -1
- package/handlers/workers/integration-defined-workers.js +2 -5
- package/index.js +14 -4
- package/integrations/index.js +4 -0
- package/integrations/integration-router.js +31 -40
- package/integrations/utils/map-integration-dto.js +15 -1
- package/management-api/openapi/components.js +1 -7
- package/management-api/openapi/v2-operations.js +0 -4
- package/management-api/v2/dependencies.js +9 -28
- package/modules/index.js +0 -6
- package/modules/use-cases/entity-types.js +12 -36
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +32 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/schema.prisma +29 -0
- package/types/index.d.ts +1 -1
- 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 +104 -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 +23 -0
- 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/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-x-frigg-headers.js +74 -85
- package/user/user.js +27 -0
- package/database/encryption/test-support/fake-ciphertext.js +0 -15
- package/modules/entity-type-registry.js +0 -311
- package/types/modules/index.d.ts +0 -58
package/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
# Unreleased
|
|
2
|
-
|
|
3
|
-
#### Behaviour change: module credential fields are now encrypted
|
|
4
|
-
|
|
5
|
-
- `credentialFields` declared in an API module's `encryption` config (for example `Definition.encryption.credentialFields`) were never registered for encryption: `loadModuleEncryptionSchemas` required a path that does not exist and the error was swallowed. They are now registered, for modules bound by integrations and for modules registered in `appDefinition.entities` (ADR-059).
|
|
6
|
-
- Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored.
|
|
7
|
-
- Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value that fails to decrypt is returned as stored with a warning instead of failing the whole read.
|
|
8
|
-
- An app's own `encryption.schema` is no longer replaced when module fields are added to it.
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
1
|
# v1.2.2 (Fri Aug 09 2024)
|
|
13
2
|
|
|
14
3
|
#### 🐛 Bug Fix
|
package/CLAUDE.md
CHANGED
|
@@ -332,12 +332,14 @@ class MyIntegration extends IntegrationBase {
|
|
|
332
332
|
- Password-based (bcrypt hashed)
|
|
333
333
|
- Token-based (Bearer tokens)
|
|
334
334
|
- App-based (external app user IDs)
|
|
335
|
+
- Adopter JWT (`authModes.adopterJwt`, `jwtConfig`; `jose`, JWKS or HMAC; see `README.md`)
|
|
335
336
|
|
|
336
337
|
**Use Cases**:
|
|
337
338
|
- `login-user.js` - User authentication
|
|
338
339
|
- `create-individual-user.js` - Create personal account
|
|
339
340
|
- `create-organization-user.js` - Create business account
|
|
340
341
|
- `get-user-from-bearer-token.js` - Token authentication
|
|
342
|
+
- `get-user-from-adopter-jwt.js` - Adopter JWT verification, find-or-create (ADR-056 §2); `adopter-jwt-config.js` validates `jwtConfig` at load
|
|
341
343
|
|
|
342
344
|
**Repositories**:
|
|
343
345
|
- `user-repository-factory.js` - Creates database-specific repositories
|
package/README.md
CHANGED
|
@@ -305,6 +305,7 @@ Comprehensive user authentication and authorization system supporting both indiv
|
|
|
305
305
|
- **Password-based**: Traditional username/password authentication
|
|
306
306
|
- **Token-based**: Bearer token authentication with session management
|
|
307
307
|
- **App-based**: External app user ID authentication (passwordless)
|
|
308
|
+
- **Adopter token (JWT)**: A token issued by your own identity system, verified by Frigg and safe to send from a browser (see below)
|
|
308
309
|
|
|
309
310
|
**Usage:**
|
|
310
311
|
```javascript
|
|
@@ -352,6 +353,74 @@ console.log('Individual user:', user.getIndividualUser());
|
|
|
352
353
|
console.log('Organization user:', user.getOrganizationUser());
|
|
353
354
|
```
|
|
354
355
|
|
|
356
|
+
#### Adopter token (JWT) authentication
|
|
357
|
+
|
|
358
|
+
For a multi-tenant product (ADR-056 §2), let the browser send the token your identity system
|
|
359
|
+
already issues. Frigg verifies the signature and claims, then finds or creates the individual
|
|
360
|
+
user and the organization user the token names. Unlike `sharedSecret` (`x-frigg-*` headers), a
|
|
361
|
+
token cannot name a user other than the one it was issued for, so it is safe in a browser.
|
|
362
|
+
|
|
363
|
+
```javascript
|
|
364
|
+
// backend/index.js
|
|
365
|
+
const appDefinition = {
|
|
366
|
+
user: {
|
|
367
|
+
primary: 'organization',
|
|
368
|
+
individualUserRequired: true,
|
|
369
|
+
organizationUserRequired: true,
|
|
370
|
+
authModes: { adopterJwt: true, friggToken: false, sharedSecret: false },
|
|
371
|
+
jwtConfig: {
|
|
372
|
+
jwksUri: 'https://login.example.com/.well-known/jwks.json',
|
|
373
|
+
issuer: 'https://login.example.com/',
|
|
374
|
+
audience: 'my-frigg-api', // required with jwksUri
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The client sends `Authorization: Bearer <token>`. `x-frigg-appuserid` / `x-frigg-apporgid`, if
|
|
381
|
+
also sent, must match the token or the request is refused with 403. That match rule lives in
|
|
382
|
+
`user/use-cases/authenticate-user.js` (`validateUserMatch`), not in the token verifier. A bearer
|
|
383
|
+
value that is not a three-part JWT is not offered to the verifier at all; it falls through to
|
|
384
|
+
`friggToken` (or is a 401 when that mode is off).
|
|
385
|
+
|
|
386
|
+
| `jwtConfig` field | Default | Meaning |
|
|
387
|
+
|---|---|---|
|
|
388
|
+
| `jwksUri` | | Remote JWKS (rotating keys, one per verifier, cached by jose). Must be `https`; `http` only for `localhost`, `127.0.0.0/8` or `::1`. Set exactly one of this and `secret`. |
|
|
389
|
+
| `secret` | | Shared HMAC secret. Read it from an environment variable. |
|
|
390
|
+
| `issuer` | not checked | Required `iss`. With `jwksUri`, a startup warning is logged when it is unset. |
|
|
391
|
+
| `audience` | not checked with `secret`; **required with `jwksUri`** | Accepted `aud` (a string or a list). |
|
|
392
|
+
| `algorithms` | `['ES256','RS256']` with `jwksUri`, `['HS256']` with `secret` | Allow-list. HS* is only accepted with `secret`, never with `jwksUri`. The older single `algorithm` is a deprecated alias; setting both is an error. |
|
|
393
|
+
| `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
|
|
394
|
+
| `userIdClaim` | `sub` | App user id (required in the token). |
|
|
395
|
+
| `orgIdClaim` | `org_id` | App organization id (optional). |
|
|
396
|
+
| `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). |
|
|
397
|
+
|
|
398
|
+
Rules worth knowing:
|
|
399
|
+
|
|
400
|
+
- Tokens must carry `exp`. `alg: none` and any algorithm outside the allow-list are refused.
|
|
401
|
+
- Startup fails (`loadAppDefinition` throws) when `adopterJwt` is on and the config is unusable:
|
|
402
|
+
neither or both of `jwksUri` and `secret`, a `jwksUri` that is not https (or loopback http),
|
|
403
|
+
no `audience` with `jwksUri`, or an algorithm list that does not fit the key type.
|
|
404
|
+
- Failures are `401` with a stable `data.reason`: `token_expired`, `token_not_yet_valid`,
|
|
405
|
+
`token_invalid_signature`, `token_wrong_issuer`, `token_wrong_audience`,
|
|
406
|
+
`token_missing_user_claim`, `token_malformed`. The message never contains the token or its claims.
|
|
407
|
+
Any other verification failure, such as an unreachable or unresolvable JWKS, is `503`
|
|
408
|
+
`jwks_unavailable`, not a 401, so clients do not drop good sessions.
|
|
409
|
+
- **The token's organization is the request's active organization**, and a person can belong to
|
|
410
|
+
several (ADR-056 §3.6). The first time a person presents a token for an organization they are
|
|
411
|
+
not a member of, Frigg adds an `OrganizationMembership` and the request runs as that
|
|
412
|
+
organization (`user.getActiveOrganizationId()`; `user.getId()` under `primary: 'organization'`).
|
|
413
|
+
Nothing is moved: `IndividualUser.organizationId` is a legacy pointer, set only on a person's
|
|
414
|
+
first link and read as an implicit membership. The same applies to `x-frigg-apporgid` with
|
|
415
|
+
`sharedSecret`. With `user.strictMembership: true` those headers cannot add anyone to an organization
|
|
416
|
+
that already existed (400 for a non-member, a brand-new `appUserId` included); only an organization
|
|
417
|
+
the request itself creates is allowed. `strictUserValidation` is a deprecated alias with that same
|
|
418
|
+
meaning. See
|
|
419
|
+
[Organization memberships](../../docs/guides/organization-memberships.md). A token naming a second
|
|
420
|
+
organization is no longer a `403` (`multi_org_unsupported` is gone).
|
|
421
|
+
- Two first logins for the same new person or organization, even in different organizations, both
|
|
422
|
+
succeed: a create that loses to a unique constraint finds the winner's row.
|
|
423
|
+
|
|
355
424
|
### 8. Lambda Utilities (`/lambda`)
|
|
356
425
|
|
|
357
426
|
AWS Lambda-specific utilities and helpers.
|
|
@@ -30,9 +30,8 @@ const {
|
|
|
30
30
|
PatchIntegrationConfig,
|
|
31
31
|
} = require('../../integrations/use-cases/patch-integration-config');
|
|
32
32
|
const {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
} = require('../../modules/entity-type-registry');
|
|
33
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
34
|
+
} = require('../../integrations/utils/map-integration-dto');
|
|
36
35
|
|
|
37
36
|
const ERROR_CODE_MAP = {
|
|
38
37
|
ENTITY_NOT_FOUND: 401,
|
|
@@ -52,16 +51,7 @@ function mapErrorToResponse(error) {
|
|
|
52
51
|
};
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
* @param {Object} [params]
|
|
57
|
-
* @param {Function} [params.integrationClass]
|
|
58
|
-
* @param {Array<object>} [params.moduleDefinitions] - the app's offered module
|
|
59
|
-
* definitions (`loadAppDefinition().moduleDefinitions`). Integration code
|
|
60
|
-
* calls this from a constructor with no app definition in hand, so when it
|
|
61
|
-
* is omitted the commands hydrate with this integration's own modules only
|
|
62
|
-
* (a registry built from `{ integrations: [integrationClass] }`).
|
|
63
|
-
*/
|
|
64
|
-
function createIntegrationCommands({ integrationClass, moduleDefinitions } = {}) {
|
|
54
|
+
function createIntegrationCommands({ integrationClass } = {}) {
|
|
65
55
|
// Always use Frigg's default repositories and use cases
|
|
66
56
|
const integrationRepository = createIntegrationRepository();
|
|
67
57
|
|
|
@@ -118,13 +108,13 @@ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {})
|
|
|
118
108
|
|
|
119
109
|
const moduleRepository = createModuleRepository();
|
|
120
110
|
|
|
111
|
+
const moduleDefinitions = getModulesDefinitionFromIntegrationClasses([
|
|
112
|
+
integrationClass,
|
|
113
|
+
]);
|
|
114
|
+
|
|
121
115
|
const moduleFactory = new ModuleFactory({
|
|
122
116
|
moduleRepository,
|
|
123
|
-
moduleDefinitions
|
|
124
|
-
moduleDefinitions ??
|
|
125
|
-
getEntityTypeDefinitions(
|
|
126
|
-
getEntityTypeRegistry({ integrations: [integrationClass] })
|
|
127
|
-
),
|
|
117
|
+
moduleDefinitions,
|
|
128
118
|
});
|
|
129
119
|
|
|
130
120
|
const loadIntegrationContextUseCase = new LoadIntegrationContextUseCase({
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
const {
|
|
2
2
|
createUserRepository,
|
|
3
3
|
} = require('../../user/repositories/user-repository-factory');
|
|
4
|
+
const {
|
|
5
|
+
createOrganizationMembershipRepository,
|
|
6
|
+
} = require('../../user/repositories/organization-membership-repository-factory');
|
|
7
|
+
const {
|
|
8
|
+
FindIndividualUsersByOrganization,
|
|
9
|
+
} = require('../../user/use-cases/find-individual-users-by-organization');
|
|
4
10
|
|
|
5
11
|
const ERROR_CODE_MAP = {
|
|
6
12
|
USER_NOT_FOUND: 404,
|
|
@@ -26,6 +32,12 @@ function mapErrorToResponse(error) {
|
|
|
26
32
|
*/
|
|
27
33
|
function createUserCommands() {
|
|
28
34
|
const userRepository = createUserRepository();
|
|
35
|
+
const findIndividualUsersByOrganization =
|
|
36
|
+
new FindIndividualUsersByOrganization({
|
|
37
|
+
userRepository,
|
|
38
|
+
organizationMembershipRepository:
|
|
39
|
+
createOrganizationMembershipRepository(),
|
|
40
|
+
});
|
|
29
41
|
|
|
30
42
|
return {
|
|
31
43
|
/**
|
|
@@ -170,7 +182,8 @@ function createUserCommands() {
|
|
|
170
182
|
},
|
|
171
183
|
|
|
172
184
|
/**
|
|
173
|
-
* Find all individual users
|
|
185
|
+
* Find all individual users who belong to an organization user: those with
|
|
186
|
+
* a membership and those the legacy organizationId pointer places there.
|
|
174
187
|
* @param {string} organizationUserId - Organization user ID to search for
|
|
175
188
|
* @returns {Promise<Object[]>} Array of individual user objects (empty if none)
|
|
176
189
|
*/
|
|
@@ -182,10 +195,9 @@ function createUserCommands() {
|
|
|
182
195
|
throw error;
|
|
183
196
|
}
|
|
184
197
|
|
|
185
|
-
const users =
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
);
|
|
198
|
+
const users = await findIndividualUsersByOrganization.execute(
|
|
199
|
+
organizationUserId
|
|
200
|
+
);
|
|
189
201
|
|
|
190
202
|
return (users || []).map((user) => ({
|
|
191
203
|
id: user._id?.toString() || user.id,
|
package/application/index.js
CHANGED
|
@@ -17,7 +17,6 @@ const { createUsageCommands } = require('./commands/usage-commands');
|
|
|
17
17
|
*
|
|
18
18
|
* @param {Object} params
|
|
19
19
|
* @param {Object} params.integrationClass - Integration class (required)
|
|
20
|
-
* @param {Array<object>} [params.moduleDefinitions] - the app's offered module definitions; defaults to the integration's own modules
|
|
21
20
|
* @returns {Object} Unified commands object with all CRUD operations
|
|
22
21
|
*
|
|
23
22
|
* @example
|
|
@@ -25,12 +24,9 @@ const { createUsageCommands } = require('./commands/usage-commands');
|
|
|
25
24
|
* const user = await commands.createUser({ username: 'user@example.com' });
|
|
26
25
|
* const credential = await commands.createCredential({ userId: user.id, ... });
|
|
27
26
|
*/
|
|
28
|
-
function createFriggCommands({ integrationClass
|
|
27
|
+
function createFriggCommands({ integrationClass }) {
|
|
29
28
|
// All commands use Frigg's default repositories and use cases
|
|
30
|
-
const integrationCommands = createIntegrationCommands({
|
|
31
|
-
integrationClass,
|
|
32
|
-
moduleDefinitions,
|
|
33
|
-
});
|
|
29
|
+
const integrationCommands = createIntegrationCommands({ integrationClass });
|
|
34
30
|
|
|
35
31
|
const userCommands = createUserCommands();
|
|
36
32
|
|
|
@@ -58,7 +58,12 @@ async function insertOne(client, collection, document) {
|
|
|
58
58
|
|
|
59
59
|
// Provide helpful context for common errors
|
|
60
60
|
if (error.code === 11000) {
|
|
61
|
-
|
|
61
|
+
// `code` lets callers recognise a lost race without parsing text
|
|
62
|
+
// (see database/unique-violation.js).
|
|
63
|
+
throw Object.assign(
|
|
64
|
+
new Error(`${errorMsg} - Duplicate key violation`),
|
|
65
|
+
{ code: 11000 }
|
|
66
|
+
);
|
|
62
67
|
}
|
|
63
68
|
throw new Error(errorMsg);
|
|
64
69
|
}
|
|
@@ -428,8 +428,6 @@ Format: "keyId:encryptedText:encryptedKey"
|
|
|
428
428
|
Example: "base64KeyId:iv:ciphertext:base64EncryptedDataKey"
|
|
429
429
|
```
|
|
430
430
|
|
|
431
|
-
**Recognising ciphertext on read.** A value is decrypted only when it has exactly four colon-separated parts: base64 `keyId`, 32-hex-character `iv`, hex ciphertext, base64 `encryptedKey`. Plaintext that merely contains colons (`https://user:pw@host:443`) is left alone. A value with that shape that still fails to decrypt is returned as stored and logged as a warning, so one bad field never fails a whole read. A field that becomes encrypted later (for example a module's `credentialFields`) stays plaintext in existing rows until they are next written.
|
|
432
|
-
|
|
433
431
|
**Why Envelope Encryption?**
|
|
434
432
|
|
|
435
433
|
- Reduces KMS API calls (one DEK per field, cached)
|
|
@@ -10,11 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
const { logger } = require('./logger');
|
|
12
12
|
const { registerDeniedKeys } = require('../../logs/denied-keys');
|
|
13
|
-
const {
|
|
14
|
-
getEntityTypeRegistry,
|
|
15
|
-
getEntityTypeDefinitions,
|
|
16
|
-
EntityRegistryError,
|
|
17
|
-
} = require('../../modules/entity-type-registry');
|
|
18
13
|
|
|
19
14
|
/**
|
|
20
15
|
* Core encryption schema (immutable - cannot be overridden by custom schemas)
|
|
@@ -171,9 +166,8 @@ function extractCredentialFieldsFromModules(moduleDefinitions) {
|
|
|
171
166
|
* Each module can declare credentialFields to encrypt in its encryption config.
|
|
172
167
|
*
|
|
173
168
|
* @param {Array} integrations - Array of integration classes with modules
|
|
174
|
-
* @param {object} [entities] - `appDefinition.entities` (ADR-059 registry)
|
|
175
169
|
*/
|
|
176
|
-
function loadModuleEncryptionSchemas(integrations
|
|
170
|
+
function loadModuleEncryptionSchemas(integrations) {
|
|
177
171
|
if (!integrations) {
|
|
178
172
|
throw new Error('integrations parameter is required');
|
|
179
173
|
}
|
|
@@ -182,39 +176,27 @@ function loadModuleEncryptionSchemas(integrations, entities) {
|
|
|
182
176
|
throw new Error('integrations must be an array');
|
|
183
177
|
}
|
|
184
178
|
|
|
185
|
-
|
|
186
|
-
// registers without any integration still carries credentials to encrypt.
|
|
187
|
-
if (integrations.length === 0 && !entities) {
|
|
179
|
+
if (integrations.length === 0) {
|
|
188
180
|
return;
|
|
189
181
|
}
|
|
190
182
|
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
);
|
|
183
|
+
const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
|
|
184
|
+
|
|
185
|
+
const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations);
|
|
194
186
|
const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions);
|
|
195
187
|
|
|
196
|
-
|
|
197
|
-
// them. Everything the app registered itself stays: module fields are added
|
|
198
|
-
// to the custom schema, never substituted for it (other models included).
|
|
199
|
-
const coreFields = new Set(CORE_ENCRYPTION_SCHEMA.Credential?.fields || []);
|
|
200
|
-
const fields = [
|
|
201
|
-
...new Set([
|
|
202
|
-
...(customSchema.Credential?.fields || []),
|
|
203
|
-
...credentialFields.filter((field) => !coreFields.has(field)),
|
|
204
|
-
]),
|
|
205
|
-
];
|
|
206
|
-
|
|
207
|
-
if (fields.length === 0) {
|
|
188
|
+
if (credentialFields.length === 0) {
|
|
208
189
|
return;
|
|
209
190
|
}
|
|
210
191
|
|
|
211
192
|
const moduleSchema = {
|
|
212
|
-
|
|
213
|
-
|
|
193
|
+
Credential: {
|
|
194
|
+
fields: credentialFields
|
|
195
|
+
}
|
|
214
196
|
};
|
|
215
197
|
|
|
216
198
|
logger.info(
|
|
217
|
-
`Registering module-level encryption for ${
|
|
199
|
+
`Registering module-level encryption for ${credentialFields.length} credential fields`
|
|
218
200
|
);
|
|
219
201
|
|
|
220
202
|
registerCustomSchema(moduleSchema);
|
|
@@ -266,15 +248,10 @@ function loadCustomEncryptionSchema() {
|
|
|
266
248
|
|
|
267
249
|
// Load module-level encryption schemas from integrations
|
|
268
250
|
const integrations = appDefinition.integrations;
|
|
269
|
-
if (Array.isArray(integrations)
|
|
270
|
-
loadModuleEncryptionSchemas(
|
|
271
|
-
Array.isArray(integrations) ? integrations : [],
|
|
272
|
-
appDefinition.entities
|
|
273
|
-
);
|
|
251
|
+
if (integrations && Array.isArray(integrations)) {
|
|
252
|
+
loadModuleEncryptionSchemas(integrations);
|
|
274
253
|
}
|
|
275
254
|
} catch (error) {
|
|
276
|
-
// A bad entity registry is a configuration error, not "no custom schema".
|
|
277
|
-
if (error instanceof EntityRegistryError) throw error;
|
|
278
255
|
// Silently ignore errors - custom schema is optional
|
|
279
256
|
// This handles cases like:
|
|
280
257
|
// - Backend package.json not found (tests, standalone usage)
|
|
@@ -4,16 +4,6 @@
|
|
|
4
4
|
* Infrastructure layer service that orchestrates field-level encryption/decryption.
|
|
5
5
|
* Handles nested JSON paths (e.g., 'data.access_token') and bulk operations.
|
|
6
6
|
*/
|
|
7
|
-
const { logger } = require('./logger');
|
|
8
|
-
|
|
9
|
-
// Cryptor.encrypt() returns `${keyId}:${iv}:${ciphertext}:${encryptedKey}`:
|
|
10
|
-
// keyId and encryptedKey are base64, iv is 16 bytes of hex, ciphertext is hex.
|
|
11
|
-
// Plaintext with three colons (`https://user:pw@host:443`, `a:b:c:d`) must not
|
|
12
|
-
// be mistaken for it.
|
|
13
|
-
const BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
14
|
-
const IV_PART = /^[0-9a-f]{32}$/i;
|
|
15
|
-
const CIPHERTEXT_PART = /^(?:[0-9a-f]{2})+$/i;
|
|
16
|
-
|
|
17
7
|
class FieldEncryptionService {
|
|
18
8
|
constructor({ cryptor, schema }) {
|
|
19
9
|
if (!cryptor) {
|
|
@@ -107,23 +97,12 @@ class FieldEncryptionService {
|
|
|
107
97
|
const decryptionPromises = fields.map(async (fieldPath) => {
|
|
108
98
|
const value = this._getNestedValue(decrypted, fieldPath);
|
|
109
99
|
|
|
110
|
-
if (
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
try {
|
|
100
|
+
if (this._isEncrypted(value)) {
|
|
114
101
|
const decryptedValue = await this.cryptor.decrypt(value);
|
|
115
102
|
const deserializedValue = this._deserializeAfterDecryption(decryptedValue);
|
|
116
103
|
return { fieldPath, decryptedValue: deserializedValue };
|
|
117
|
-
} catch (error) {
|
|
118
|
-
// A value that looks like ciphertext but is not (or was written
|
|
119
|
-
// before the field was encrypted) is read as it is stored. One
|
|
120
|
-
// such field must not fail the whole read, findMany included.
|
|
121
|
-
logger.warn(
|
|
122
|
-
`Could not decrypt ${modelName}.${fieldPath}; returning the stored value`,
|
|
123
|
-
{ errorName: error?.name }
|
|
124
|
-
);
|
|
125
|
-
return null;
|
|
126
104
|
}
|
|
105
|
+
return null;
|
|
127
106
|
});
|
|
128
107
|
|
|
129
108
|
const results = await Promise.all(decryptionPromises);
|
|
@@ -173,13 +152,7 @@ class FieldEncryptionService {
|
|
|
173
152
|
}
|
|
174
153
|
|
|
175
154
|
const parts = value.split(':');
|
|
176
|
-
return
|
|
177
|
-
parts.length === 4 &&
|
|
178
|
-
BASE64_PART.test(parts[0]) &&
|
|
179
|
-
IV_PART.test(parts[1]) &&
|
|
180
|
-
CIPHERTEXT_PART.test(parts[2]) &&
|
|
181
|
-
BASE64_PART.test(parts[3])
|
|
182
|
-
);
|
|
155
|
+
return parts.length >= 4;
|
|
183
156
|
}
|
|
184
157
|
|
|
185
158
|
_getNestedValue(obj, path) {
|
|
@@ -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
|
+
};
|