@friggframework/core 2.0.0--canary.679.a2bcc2f.0 → 2.0.0--canary.686.87e9875.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 +25 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/user-commands.js +17 -5
- package/core/Worker.js +31 -7
- package/credential/use-cases/credential-operations-for-user.js +110 -25
- package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/encryption-schema-registry.js +80 -12
- package/database/organization-where.js +18 -0
- package/database/prisma.js +4 -2
- package/database/unique-violation.js +25 -0
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +167 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +27 -4
- package/generated/prisma-mongodb/index-browser.js +24 -1
- package/generated/prisma-mongodb/index.d.ts +3227 -47
- package/generated/prisma-mongodb/index.js +27 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +65 -0
- package/generated/prisma-mongodb/wasm.js +27 -4
- package/generated/prisma-postgresql/edge.js +27 -4
- package/generated/prisma-postgresql/index-browser.js +24 -1
- package/generated/prisma-postgresql/index.d.ts +3519 -67
- package/generated/prisma-postgresql/index.js +27 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +61 -0
- package/generated/prisma-postgresql/wasm.js +27 -4
- package/handlers/app-definition-loader.js +93 -12
- package/handlers/routers/auth.js +40 -12
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/index.js +58 -1
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +185 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +36 -7
- package/integrations/integration-router.js +115 -73
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -3
- package/integrations/repositories/integration-repository-interface.js +17 -1
- package/integrations/repositories/integration-repository-mongo.js +59 -76
- package/integrations/repositories/integration-repository-postgres.js +62 -76
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- 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 +55 -24
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/management-api/management-api-config.js +71 -2
- package/management-api/openapi/components.js +40 -0
- package/management-api/openapi/v2-operations.js +16 -4
- package/management-api/v2/dependencies.js +40 -15
- package/management-api/v2/dto.js +20 -0
- package/management-api/v2/handlers/authorization.js +1 -0
- package/management-api/v2/handlers/credentials.js +6 -2
- package/management-api/v2/handlers/entities.js +10 -7
- package/management-api/v2/handlers/integrations.js +6 -3
- package/management-api/v2/request.js +32 -1
- package/modules/entity-scope.js +15 -0
- package/modules/index.js +8 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +35 -2
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +50 -13
- package/modules/use-cases/entity-types.js +31 -7
- package/modules/use-cases/execute-entity-proxy-request.js +50 -3
- package/modules/use-cases/get-entities-for-user.js +16 -5
- package/modules/use-cases/get-entity-module-for-user.js +32 -5
- package/modules/use-cases/get-entity-options-by-id.js +9 -3
- package/modules/use-cases/get-module.js +9 -3
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +39 -12
- package/modules/use-cases/process-authorization-callback.js +52 -18
- package/modules/use-cases/refresh-entity-options.js +9 -3
- package/modules/use-cases/test-module-auth.js +9 -3
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +65 -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/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +61 -0
- package/providers/index.js +13 -0
- package/providers/resolve-provider.js +93 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +76 -0
- package/queues/providers/qstash-queue-provider.js +122 -0
- package/queues/providers/sqs-queue-provider.js +61 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +46 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +129 -0
- package/types/user/index.d.ts +134 -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/use-cases/authenticate-user.js +7 -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 +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
2
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
3
|
+
const { entityNotFoundError } = require('./entity-not-found');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* PostgreSQL Module Repository Adapter
|
|
@@ -108,6 +109,26 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
108
109
|
return credentialMap;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The one place a stored entity row becomes the entity the application sees.
|
|
114
|
+
* The provider's `data` is spread FIRST and every column is pinned after it:
|
|
115
|
+
* a stored `id`, `userId` or `organizationId` in `data` must not decide who
|
|
116
|
+
* owns the entity or who sees it.
|
|
117
|
+
* @private
|
|
118
|
+
*/
|
|
119
|
+
_mapEntity(row, credential) {
|
|
120
|
+
return {
|
|
121
|
+
...(row.data || {}),
|
|
122
|
+
id: row.id.toString(),
|
|
123
|
+
credential,
|
|
124
|
+
userId: row.userId?.toString(),
|
|
125
|
+
name: row.name,
|
|
126
|
+
externalId: row.externalId,
|
|
127
|
+
moduleName: row.moduleName,
|
|
128
|
+
organizationId: row.organizationId?.toString() ?? null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
111
132
|
/**
|
|
112
133
|
* Find entity by ID with credential
|
|
113
134
|
* Replaces: Entity.findById(entityId).populate('credential')
|
|
@@ -124,20 +145,12 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
124
145
|
});
|
|
125
146
|
|
|
126
147
|
if (!entity) {
|
|
127
|
-
throw
|
|
148
|
+
throw entityNotFoundError(entityId);
|
|
128
149
|
}
|
|
129
150
|
|
|
130
151
|
const credential = await this._fetchCredential(entity.credentialId);
|
|
131
152
|
|
|
132
|
-
return
|
|
133
|
-
id: entity.id.toString(),
|
|
134
|
-
credential,
|
|
135
|
-
userId: entity.userId?.toString(),
|
|
136
|
-
name: entity.name,
|
|
137
|
-
externalId: entity.externalId,
|
|
138
|
-
moduleName: entity.moduleName,
|
|
139
|
-
...(entity.data || {}),
|
|
140
|
-
};
|
|
153
|
+
return this._mapEntity(entity, credential);
|
|
141
154
|
}
|
|
142
155
|
|
|
143
156
|
/**
|
|
@@ -157,15 +170,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
157
170
|
const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
|
|
158
171
|
const credentialMap = await this._fetchCredentialsBulk(credentialIds);
|
|
159
172
|
|
|
160
|
-
return entities.map((e) => (
|
|
161
|
-
id: e.id.toString(),
|
|
162
|
-
credential: credentialMap.get(e.credentialId) || null,
|
|
163
|
-
userId: e.userId?.toString(),
|
|
164
|
-
name: e.name,
|
|
165
|
-
externalId: e.externalId,
|
|
166
|
-
moduleName: e.moduleName,
|
|
167
|
-
...(e.data || {}),
|
|
168
|
-
}));
|
|
173
|
+
return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
/**
|
|
@@ -185,15 +190,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
185
190
|
const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
|
|
186
191
|
const credentialMap = await this._fetchCredentialsBulk(credentialIds);
|
|
187
192
|
|
|
188
|
-
return entities.map((e) => (
|
|
189
|
-
id: e.id.toString(),
|
|
190
|
-
credential: credentialMap.get(e.credentialId) || null,
|
|
191
|
-
userId: e.userId?.toString(),
|
|
192
|
-
name: e.name,
|
|
193
|
-
externalId: e.externalId,
|
|
194
|
-
moduleName: e.moduleName,
|
|
195
|
-
...(e.data || {}),
|
|
196
|
-
}));
|
|
193
|
+
return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
|
|
197
194
|
}
|
|
198
195
|
|
|
199
196
|
/**
|
|
@@ -217,15 +214,32 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
217
214
|
const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
|
|
218
215
|
const credentialMap = await this._fetchCredentialsBulk(credentialIds);
|
|
219
216
|
|
|
220
|
-
return entities.map((e) => (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
217
|
+
return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Find entities matching any of the owner/organization pairs, in one query.
|
|
222
|
+
* `organizationId: null` matches rows with no organization. The caller
|
|
223
|
+
* (a use case) decides which pairs; this only runs them.
|
|
224
|
+
*
|
|
225
|
+
* @param {Array<{userId: string, organizationId: (string|null)}>} clauses
|
|
226
|
+
* @returns {Promise<Array>} Array of entity objects with string IDs
|
|
227
|
+
*/
|
|
228
|
+
async findEntitiesByOwnership(clauses) {
|
|
229
|
+
if (!Array.isArray(clauses) || clauses.length === 0) return [];
|
|
230
|
+
const entities = await this.prisma.entity.findMany({
|
|
231
|
+
where: {
|
|
232
|
+
OR: clauses.map(({ userId, organizationId }) => ({
|
|
233
|
+
userId: this._convertId(userId),
|
|
234
|
+
organizationId: this._convertId(organizationId),
|
|
235
|
+
})),
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
|
|
240
|
+
const credentialMap = await this._fetchCredentialsBulk(credentialIds);
|
|
241
|
+
|
|
242
|
+
return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
|
|
229
243
|
}
|
|
230
244
|
|
|
231
245
|
/**
|
|
@@ -264,15 +278,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
264
278
|
|
|
265
279
|
const credential = await this._fetchCredential(entity.credentialId);
|
|
266
280
|
|
|
267
|
-
return
|
|
268
|
-
id: entity.id.toString(),
|
|
269
|
-
credential,
|
|
270
|
-
userId: entity.userId?.toString(),
|
|
271
|
-
name: entity.name,
|
|
272
|
-
externalId: entity.externalId,
|
|
273
|
-
moduleName: entity.moduleName,
|
|
274
|
-
...(entity.data || {}),
|
|
275
|
-
};
|
|
281
|
+
return this._mapEntity(entity, credential);
|
|
276
282
|
}
|
|
277
283
|
|
|
278
284
|
/**
|
|
@@ -292,15 +298,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
292
298
|
.filter(Boolean);
|
|
293
299
|
const credentialMap = await this._fetchCredentialsBulk(credentialIds);
|
|
294
300
|
|
|
295
|
-
return entities.map((e) => (
|
|
296
|
-
id: e.id.toString(),
|
|
297
|
-
credential: credentialMap.get(e.credentialId) || null,
|
|
298
|
-
userId: e.userId?.toString(),
|
|
299
|
-
name: e.name,
|
|
300
|
-
externalId: e.externalId,
|
|
301
|
-
moduleName: e.moduleName,
|
|
302
|
-
...(e.data || {}),
|
|
303
|
-
}));
|
|
301
|
+
return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
|
|
304
302
|
}
|
|
305
303
|
|
|
306
304
|
/**
|
|
@@ -308,9 +306,13 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
308
306
|
* Replaces: Entity.create(entityData)
|
|
309
307
|
*
|
|
310
308
|
* @param {Object} entityData - Entity data (with string IDs from application layer)
|
|
309
|
+
* @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity
|
|
310
|
+
* belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`,
|
|
311
|
+
* because `entityData` carries provider fields and one named `organizationId`
|
|
312
|
+
* must not be taken for ours.
|
|
311
313
|
* @returns {Promise<Object>} Created entity object with string IDs
|
|
312
314
|
*/
|
|
313
|
-
async createEntity(entityData) {
|
|
315
|
+
async createEntity(entityData, tenancy = {}) {
|
|
314
316
|
const {
|
|
315
317
|
user,
|
|
316
318
|
userId,
|
|
@@ -328,6 +330,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
328
330
|
name,
|
|
329
331
|
moduleName,
|
|
330
332
|
externalId,
|
|
333
|
+
organizationId: this._convertId(tenancy.organizationId ?? null),
|
|
331
334
|
data: dynamicData,
|
|
332
335
|
};
|
|
333
336
|
|
|
@@ -337,15 +340,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
337
340
|
|
|
338
341
|
const credentialObj = await this._fetchCredential(entity.credentialId);
|
|
339
342
|
|
|
340
|
-
return
|
|
341
|
-
id: entity.id.toString(),
|
|
342
|
-
credential: credentialObj,
|
|
343
|
-
userId: entity.userId?.toString(),
|
|
344
|
-
name: entity.name,
|
|
345
|
-
externalId: entity.externalId,
|
|
346
|
-
moduleName: entity.moduleName,
|
|
347
|
-
...(entity.data || {}),
|
|
348
|
-
};
|
|
343
|
+
return this._mapEntity(entity, credentialObj);
|
|
349
344
|
}
|
|
350
345
|
|
|
351
346
|
/**
|
|
@@ -401,15 +396,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
401
396
|
|
|
402
397
|
const credentialObj = await this._fetchCredential(entity.credentialId);
|
|
403
398
|
|
|
404
|
-
return
|
|
405
|
-
id: entity.id.toString(),
|
|
406
|
-
credential: credentialObj,
|
|
407
|
-
userId: entity.userId?.toString(),
|
|
408
|
-
name: entity.name,
|
|
409
|
-
externalId: entity.externalId,
|
|
410
|
-
moduleName: entity.moduleName,
|
|
411
|
-
...(entity.data || {}),
|
|
412
|
-
};
|
|
399
|
+
return this._mapEntity(entity, credentialObj);
|
|
413
400
|
} catch (error) {
|
|
414
401
|
if (error.code === 'P2025') {
|
|
415
402
|
return null;
|
|
@@ -474,6 +461,11 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
474
461
|
if (filter.moduleName) where.moduleName = filter.moduleName;
|
|
475
462
|
if (filter.externalId) where.externalId = this._toString(filter.externalId);
|
|
476
463
|
|
|
464
|
+
// `null` means "no organization"; absent means "any".
|
|
465
|
+
if (filter.organizationId !== undefined) {
|
|
466
|
+
where.organizationId = this._convertId(filter.organizationId);
|
|
467
|
+
}
|
|
468
|
+
|
|
477
469
|
return where;
|
|
478
470
|
}
|
|
479
471
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
2
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
3
|
+
const { entityNotFoundError } = require('./entity-not-found');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Prisma-based Module Repository
|
|
@@ -37,7 +38,7 @@ class ModuleRepository extends ModuleRepositoryInterface {
|
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
if (!entity) {
|
|
40
|
-
throw
|
|
41
|
+
throw entityNotFoundError(entityId);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
return {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and validating ownership settings in an app definition (ADR-056
|
|
3
|
+
* §3.1, §3.3). Called once by loadAppDefinition, so a bad setting stops the app
|
|
4
|
+
* at load, not on the first request. No I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
SCOPES,
|
|
9
|
+
INDIVIDUAL_ACROSS_VALUES,
|
|
10
|
+
OWNER_RULES,
|
|
11
|
+
DEFAULT_ORG_ADMIN_ROLES,
|
|
12
|
+
configError,
|
|
13
|
+
isPlainObject,
|
|
14
|
+
parseScopeSetting,
|
|
15
|
+
resolveScope,
|
|
16
|
+
} = require('./scope-resolution');
|
|
17
|
+
const { entityTypeNameOf } = require('./entity-type-registry');
|
|
18
|
+
|
|
19
|
+
/** The binding scope an integration declares for `modules[key]`, if any. */
|
|
20
|
+
function bindingScopeSetting(Definition, key) {
|
|
21
|
+
const binding = Definition?.modules?.[key];
|
|
22
|
+
if (binding?.scope !== undefined) return binding.scope;
|
|
23
|
+
const entityEntry = Definition?.entities?.[key];
|
|
24
|
+
if (entityEntry?.scope !== undefined) return entityEntry.scope;
|
|
25
|
+
// Deprecated alias from the global-entities branch: `global: true`.
|
|
26
|
+
if (entityEntry?.global === true) return SCOPES.GLOBAL;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readAppOwnership(appDefinition) {
|
|
31
|
+
const raw = appDefinition?.ownership;
|
|
32
|
+
if (raw === undefined || raw === null) {
|
|
33
|
+
return { scope: null, individualAcross: 'organization' };
|
|
34
|
+
}
|
|
35
|
+
if (!isPlainObject(raw)) {
|
|
36
|
+
throw configError('appDefinition.ownership', 'must be an object');
|
|
37
|
+
}
|
|
38
|
+
const unknown = Object.keys(raw).filter(
|
|
39
|
+
(key) => key !== 'scope' && key !== 'individualAcross'
|
|
40
|
+
);
|
|
41
|
+
if (unknown.length) {
|
|
42
|
+
throw configError(
|
|
43
|
+
'appDefinition.ownership',
|
|
44
|
+
`unknown setting(s): ${unknown.join(', ')}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const individualAcross = raw.individualAcross ?? 'organization';
|
|
48
|
+
if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
|
|
49
|
+
throw configError(
|
|
50
|
+
'appDefinition.ownership.individualAcross',
|
|
51
|
+
`must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
scope: parseScopeSetting(raw.scope, 'appDefinition.ownership.scope'),
|
|
56
|
+
individualAcross,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readIntegrationOwnership(integrationClass) {
|
|
61
|
+
const name = integrationClass?.Definition?.name || 'unnamed';
|
|
62
|
+
const raw = integrationClass?.Definition?.ownership;
|
|
63
|
+
if (raw === undefined || raw === null) return { scope: null, owner: 'derive' };
|
|
64
|
+
const where = `integration '${name}' ownership`;
|
|
65
|
+
if (!isPlainObject(raw)) throw configError(where, 'must be an object');
|
|
66
|
+
const unknown = Object.keys(raw).filter(
|
|
67
|
+
(key) => key !== 'scope' && key !== 'owner'
|
|
68
|
+
);
|
|
69
|
+
if (unknown.length) {
|
|
70
|
+
throw configError(where, `unknown setting(s): ${unknown.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
const owner = raw.owner ?? 'derive';
|
|
73
|
+
if (!OWNER_RULES.includes(owner)) {
|
|
74
|
+
throw configError(where, `owner must be one of ${OWNER_RULES.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `user.orgAdminRoles`: the token roles that may manage organization-level
|
|
81
|
+
* things. Default `['ADMIN']`. A shared-secret caller needs no role.
|
|
82
|
+
*/
|
|
83
|
+
function readOrgAdminRoles(appDefinition) {
|
|
84
|
+
const raw = appDefinition?.user?.orgAdminRoles;
|
|
85
|
+
if (raw === undefined || raw === null) return [...DEFAULT_ORG_ADMIN_ROLES];
|
|
86
|
+
if (
|
|
87
|
+
!Array.isArray(raw) ||
|
|
88
|
+
raw.length === 0 ||
|
|
89
|
+
raw.some((role) => typeof role !== 'string' || role.trim() === '')
|
|
90
|
+
) {
|
|
91
|
+
throw configError(
|
|
92
|
+
'appDefinition.user.orgAdminRoles',
|
|
93
|
+
'must be a non-empty array of role names'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return [...raw];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `user.orgAdminGateBearerTokens`: judge Frigg bearer-token callers by role
|
|
101
|
+
* like adopter-token callers. Default false: they keep their pre-roles
|
|
102
|
+
* behaviour and may manage the organization they act in.
|
|
103
|
+
*/
|
|
104
|
+
function readGateBearerTokens(appDefinition) {
|
|
105
|
+
const raw = appDefinition?.user?.orgAdminGateBearerTokens;
|
|
106
|
+
if (raw === undefined || raw === null) return false;
|
|
107
|
+
if (typeof raw !== 'boolean') {
|
|
108
|
+
throw configError('appDefinition.user.orgAdminGateBearerTokens', 'must be a boolean');
|
|
109
|
+
}
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every binding of every integration with its resolved scope and where it came
|
|
115
|
+
* from, parsing each integration's settings once. Throws OwnershipConfigError
|
|
116
|
+
* for any invalid one. Exported for tooling that wants to show the chain.
|
|
117
|
+
*/
|
|
118
|
+
function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
|
|
119
|
+
const app = readAppOwnership(appDefinition);
|
|
120
|
+
const rows = [];
|
|
121
|
+
for (const integrationClass of appDefinition?.integrations || []) {
|
|
122
|
+
const Definition = integrationClass?.Definition;
|
|
123
|
+
const name = Definition?.name || 'unnamed';
|
|
124
|
+
const ownership = readIntegrationOwnership(integrationClass);
|
|
125
|
+
for (const [key, binding] of Object.entries(Definition?.modules || {})) {
|
|
126
|
+
const moduleName = entityTypeNameOf(binding?.definition);
|
|
127
|
+
const resolved = resolveScope({
|
|
128
|
+
binding: parseScopeSetting(
|
|
129
|
+
bindingScopeSetting(Definition, key),
|
|
130
|
+
`integration '${name}' binding '${key}' scope`
|
|
131
|
+
),
|
|
132
|
+
integration: ownership.scope,
|
|
133
|
+
entityType: moduleName
|
|
134
|
+
? entityTypeRegistry?.get(moduleName)?.settings?.scope
|
|
135
|
+
: undefined,
|
|
136
|
+
app: app.scope,
|
|
137
|
+
primary,
|
|
138
|
+
});
|
|
139
|
+
rows.push({
|
|
140
|
+
integration: name,
|
|
141
|
+
binding: key,
|
|
142
|
+
moduleName: moduleName ?? null,
|
|
143
|
+
scope: resolved.scope,
|
|
144
|
+
source: resolved.source,
|
|
145
|
+
owner: ownership.owner,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return rows;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validates every ownership setting in one pass and returns the app-level
|
|
154
|
+
* config the runtime needs.
|
|
155
|
+
*
|
|
156
|
+
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[], gateBearerTokens: boolean}}
|
|
157
|
+
* @throws {OwnershipConfigError}
|
|
158
|
+
*/
|
|
159
|
+
function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
160
|
+
const app = readAppOwnership(appDefinition);
|
|
161
|
+
for (const [name, entry] of entityTypeRegistry || []) {
|
|
162
|
+
parseScopeSetting(entry?.settings?.scope, `entity type '${name}' scope`);
|
|
163
|
+
}
|
|
164
|
+
const rows = listBindingScopes(
|
|
165
|
+
appDefinition,
|
|
166
|
+
entityTypeRegistry,
|
|
167
|
+
appDefinition?.user?.primary
|
|
168
|
+
);
|
|
169
|
+
// §3.3: a shared integration must not run on one person's credential.
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
if (row.owner === 'organization' && row.scope === SCOPES.INDIVIDUAL) {
|
|
172
|
+
throw configError(
|
|
173
|
+
`integration '${row.integration}' binding '${row.binding}'`,
|
|
174
|
+
"an organization-owned integration cannot bind an 'individual' entity"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
scope: app.scope,
|
|
180
|
+
individualAcross: app.individualAcross,
|
|
181
|
+
orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
|
|
182
|
+
gateBearerTokens: readGateBearerTokens(appDefinition),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
bindingScopeSetting,
|
|
188
|
+
readAppOwnership,
|
|
189
|
+
readIntegrationOwnership,
|
|
190
|
+
listBindingScopes,
|
|
191
|
+
resolveOwnershipConfig,
|
|
192
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The object a use case asks about scopes (ADR-056 §3): the app's ownership
|
|
3
|
+
* config and entity type registry bound once, as closures. With no arguments it
|
|
4
|
+
* is the behaviour of an app that configures nothing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
INDIVIDUAL_ACROSS_VALUES,
|
|
9
|
+
DEFAULT_ORG_ADMIN_ROLES,
|
|
10
|
+
configError,
|
|
11
|
+
parseScopeSetting,
|
|
12
|
+
resolveScope,
|
|
13
|
+
resolveOwnerForScope,
|
|
14
|
+
} = require('./scope-resolution');
|
|
15
|
+
const {
|
|
16
|
+
accessContext,
|
|
17
|
+
accessToOwner,
|
|
18
|
+
describeRecord,
|
|
19
|
+
} = require('./scope-visibility');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} [params]
|
|
23
|
+
* @param {{scope: *, individualAcross: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [params.ownership] - the raw
|
|
24
|
+
* `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
|
|
25
|
+
* `{ default, allow }` parses to itself)
|
|
26
|
+
* @param {Map} [params.entityTypeRegistry]
|
|
27
|
+
*/
|
|
28
|
+
function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
29
|
+
const appScope = parseScopeSetting(
|
|
30
|
+
ownership?.scope,
|
|
31
|
+
'appDefinition.ownership.scope'
|
|
32
|
+
);
|
|
33
|
+
const individualAcross = ownership?.individualAcross ?? 'organization';
|
|
34
|
+
if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
|
|
35
|
+
throw configError(
|
|
36
|
+
'appDefinition.ownership.individualAcross',
|
|
37
|
+
`must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
|
|
42
|
+
const gateBearerTokens = ownership?.gateBearerTokens === true;
|
|
43
|
+
|
|
44
|
+
const contextFor = (user) =>
|
|
45
|
+
accessContext(user, { individualAcross, orgAdminRoles, gateBearerTokens });
|
|
46
|
+
|
|
47
|
+
/** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
|
|
48
|
+
const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
|
|
49
|
+
resolveScope({
|
|
50
|
+
request: requestScope,
|
|
51
|
+
binding,
|
|
52
|
+
integration,
|
|
53
|
+
entityType: entityTypeRegistry?.get?.(entityType)?.settings?.scope,
|
|
54
|
+
app: appScope,
|
|
55
|
+
primary: user?.config?.primary,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scope, then owner and organization, for a new entity. `legacyOrgId` (the
|
|
60
|
+
* person's legacy organization pointer) rides along so a later lookup can
|
|
61
|
+
* reuse an entity written before scoping.
|
|
62
|
+
*/
|
|
63
|
+
const resolveOwnership = ({ entityType, requestScope, user }) => {
|
|
64
|
+
const ctx = contextFor(user);
|
|
65
|
+
const resolved = resolveEntityScope({ entityType, requestScope, user });
|
|
66
|
+
return {
|
|
67
|
+
...resolveOwnerForScope(resolved.scope, ctx),
|
|
68
|
+
legacyOrgId: ctx.legacyOrgId,
|
|
69
|
+
source: resolved.source,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The owner and organization for a scope that is already decided: a
|
|
75
|
+
* re-authorization keeps the scope its connection has, whatever the config
|
|
76
|
+
* says now. The admin gate applies as it does on create.
|
|
77
|
+
*/
|
|
78
|
+
const ownershipForScope = ({ scope, user }) => {
|
|
79
|
+
const ctx = contextFor(user);
|
|
80
|
+
return {
|
|
81
|
+
...resolveOwnerForScope(scope, ctx),
|
|
82
|
+
legacyOrgId: ctx.legacyOrgId,
|
|
83
|
+
source: 'fixed',
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Whether `user` may load this stored entity for use (same clauses as the list). */
|
|
88
|
+
const canUseEntity = (user, entity) =>
|
|
89
|
+
describeRecord(entity, contextFor(user), 'entity') !== null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What `user` may do with a record owned by `ownerId` that has no
|
|
93
|
+
* organization of its own (a credential): manage, or null when not theirs.
|
|
94
|
+
*/
|
|
95
|
+
const accessToOwnedBy = (user, ownerId) => accessToOwner(ownerId, contextFor(user));
|
|
96
|
+
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
individualAcross,
|
|
99
|
+
orgAdminRoles,
|
|
100
|
+
accessToOwnedBy,
|
|
101
|
+
accessContext: contextFor,
|
|
102
|
+
resolveScope: resolveEntityScope,
|
|
103
|
+
resolveOwnership,
|
|
104
|
+
ownershipForScope,
|
|
105
|
+
canUseEntity,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The policy of an app that configures nothing; what a use case gets by default. */
|
|
110
|
+
const DEFAULT_SCOPE_POLICY = createScopePolicy();
|
|
111
|
+
|
|
112
|
+
module.exports = { createScopePolicy, DEFAULT_SCOPE_POLICY };
|