@friggframework/core 2.0.0--canary.681.2df9ef0.0 → 2.0.0--canary.683.f45da33.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/README.md +37 -1
- package/application/commands/integration-commands.js +20 -10
- package/application/index.js +6 -2
- package/credential/use-cases/credential-operations-for-user.js +9 -1
- package/credential/use-cases/reauthorize-credential-for-user.js +14 -4
- package/database/encryption/encryption-schema-registry.js +7 -0
- package/database/organization-where.js +18 -0
- package/generated/prisma-mongodb/edge.js +5 -3
- package/generated/prisma-mongodb/index-browser.js +2 -0
- package/generated/prisma-mongodb/index.d.ts +115 -2
- package/generated/prisma-mongodb/index.js +5 -3
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +15 -0
- package/generated/prisma-mongodb/wasm.js +5 -3
- package/generated/prisma-postgresql/edge.js +5 -3
- package/generated/prisma-postgresql/index-browser.js +2 -0
- package/generated/prisma-postgresql/index.d.ts +131 -2
- package/generated/prisma-postgresql/index.js +5 -3
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +15 -0
- package/generated/prisma-postgresql/wasm.js +5 -3
- package/handlers/app-definition-loader.js +25 -8
- package/handlers/backend-utils.js +31 -25
- package/handlers/routers/health.js +1 -4
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +10 -2
- package/integrations/index.js +0 -4
- package/integrations/integration-router.js +101 -52
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +34 -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/integrations/utils/map-integration-dto.js +1 -15
- package/management-api/management-api-config.js +3 -0
- package/management-api/openapi/components.js +46 -1
- package/management-api/openapi/v2-operations.js +14 -1
- package/management-api/v2/dependencies.js +58 -14
- package/management-api/v2/dto.js +17 -0
- package/management-api/v2/handlers/authorization.js +1 -0
- 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/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/repositories/module-repository-documentdb.js +41 -1
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +64 -73
- package/modules/repositories/module-repository-postgres.js +63 -73
- package/modules/scope-config.js +177 -0
- package/modules/scope-policy.js +96 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +212 -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/entity-operations-for-user.js +50 -13
- package/modules/use-cases/entity-types.js +62 -14
- package/modules/use-cases/execute-entity-proxy-request.js +15 -0
- package/modules/use-cases/get-entities-for-user.js +16 -5
- package/modules/use-cases/get-entity-module-for-user.js +23 -3
- package/modules/use-cases/get-entity-options-by-id.js +5 -2
- package/modules/use-cases/get-module.js +5 -2
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +36 -12
- package/modules/use-cases/process-authorization-callback.js +52 -18
- package/modules/use-cases/refresh-entity-options.js +5 -2
- package/modules/use-cases/test-module-auth.js +5 -2
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +15 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/schema.prisma +15 -0
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +145 -0
- package/types/user/index.d.ts +7 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/user.js +24 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class GetModule {
|
|
4
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
5
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
5
6
|
this.moduleRepository = moduleRepository;
|
|
6
7
|
this.moduleDefinitions = moduleDefinitions;
|
|
8
|
+
// Who may load an entity follows its scope (ADR-056 §3.6).
|
|
9
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -32,7 +35,7 @@ class GetModule {
|
|
|
32
35
|
// If User object provided, use ownsUserId to check linked users
|
|
33
36
|
// Otherwise fall back to simple equality check
|
|
34
37
|
const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
|
|
35
|
-
?
|
|
38
|
+
? this.scopePolicy.canUseEntity(userIdOrUser, entity)
|
|
36
39
|
: entity.userId?.toString() === userId?.toString();
|
|
37
40
|
|
|
38
41
|
if (!isOwned) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { ACCESS } = require('../scope-resolution');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one check behind every "may this caller change or delete it" gate
|
|
6
|
+
* (ADR-056 §3 permissions): the caller may use it but not manage it, so 403
|
|
7
|
+
* `ORG_ADMIN_REQUIRED`. `access` comes from describeRecord (entities,
|
|
8
|
+
* integrations) or scopePolicy.accessToOwnedBy (credentials).
|
|
9
|
+
*
|
|
10
|
+
* @param {string|null} access - ACCESS value
|
|
11
|
+
* @param {string} subject - e.g. `entity 5`, for the message
|
|
12
|
+
*/
|
|
13
|
+
function assertCanManage(access, subject) {
|
|
14
|
+
if (access !== ACCESS.MANAGE) {
|
|
15
|
+
throw Boom.forbidden(`You may use ${subject} but not change or delete it`, {
|
|
16
|
+
code: 'ORG_ADMIN_REQUIRED',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { assertCanManage };
|
|
@@ -3,6 +3,8 @@ const { FetchError } = require('../../errors/fetch-error');
|
|
|
3
3
|
const { getLogger } = require('../../logs');
|
|
4
4
|
const { AuthorizationSession } = require('../domain/authorization-session');
|
|
5
5
|
const { findDefinition, getAuthStepCount } = require('./entity-types');
|
|
6
|
+
const { describeRecord } = require('../scope-visibility');
|
|
7
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
6
8
|
|
|
7
9
|
const log = getLogger('frigg.api.authorize');
|
|
8
10
|
|
|
@@ -144,21 +146,28 @@ class SubmitAuthorizationStep {
|
|
|
144
146
|
moduleDefinitions,
|
|
145
147
|
getAuthorizationRequirements,
|
|
146
148
|
authorizationSessionRepository,
|
|
147
|
-
|
|
149
|
+
authorizeEntityForUser,
|
|
148
150
|
moduleRepository,
|
|
149
151
|
credentialRepository,
|
|
150
152
|
createModule,
|
|
153
|
+
scopePolicy,
|
|
151
154
|
}) {
|
|
155
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
152
156
|
this.moduleDefinitions = moduleDefinitions || [];
|
|
153
157
|
this.getAuthorizationRequirements = getAuthorizationRequirements;
|
|
154
158
|
this.sessions = authorizationSessionRepository;
|
|
155
|
-
this.
|
|
159
|
+
this.authorizeEntityForUser = authorizeEntityForUser;
|
|
156
160
|
this.moduleRepository = moduleRepository;
|
|
157
161
|
this.credentialRepository = credentialRepository;
|
|
158
162
|
this.createModule = createModule || defaultCreateModule;
|
|
159
163
|
}
|
|
160
164
|
|
|
161
|
-
|
|
165
|
+
/**
|
|
166
|
+
* @param {Object} params
|
|
167
|
+
* @param {string} [params.scope] - the scope the caller asks for, from the
|
|
168
|
+
* deciding level's allow list (ADR-056 §3.1). Never `global`.
|
|
169
|
+
*/
|
|
170
|
+
async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope }) {
|
|
162
171
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
163
172
|
throw Boom.badRequest('data must be an object', {
|
|
164
173
|
code: 'VALIDATION_ERROR',
|
|
@@ -166,7 +175,15 @@ class SubmitAuthorizationStep {
|
|
|
166
175
|
});
|
|
167
176
|
}
|
|
168
177
|
const definition = findDefinition(this.moduleDefinitions, entityType);
|
|
178
|
+
// `userId` binds a session to the caller and is what GET /authorize
|
|
179
|
+
// computes too. The owner of the entity this creates is decided by
|
|
180
|
+
// scope (§3.2), not by user.primary, and is `ownership.ownerId`.
|
|
169
181
|
const userId = user.getId();
|
|
182
|
+
const ownership = this.authorizeEntityForUser.resolveOwnership({
|
|
183
|
+
user,
|
|
184
|
+
entityType,
|
|
185
|
+
scope,
|
|
186
|
+
});
|
|
170
187
|
|
|
171
188
|
if (!isMultiStep(definition)) {
|
|
172
189
|
if (step !== 1) {
|
|
@@ -174,7 +191,7 @@ class SubmitAuthorizationStep {
|
|
|
174
191
|
code: 'INVALID_STEP',
|
|
175
192
|
});
|
|
176
193
|
}
|
|
177
|
-
return this.complete({
|
|
194
|
+
return this.complete({ ownership, user, entityType, params: data, credentialId });
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
const totalSteps = getAuthStepCount(definition);
|
|
@@ -205,7 +222,7 @@ class SubmitAuthorizationStep {
|
|
|
205
222
|
);
|
|
206
223
|
}
|
|
207
224
|
|
|
208
|
-
const module = this.createModule({ userId, definition });
|
|
225
|
+
const module = this.createModule({ userId: ownership.ownerId, definition });
|
|
209
226
|
let result;
|
|
210
227
|
try {
|
|
211
228
|
result = await definition.processAuthorizationStep(
|
|
@@ -224,7 +241,8 @@ class SubmitAuthorizationStep {
|
|
|
224
241
|
session.markComplete();
|
|
225
242
|
await this.sessions.update(session);
|
|
226
243
|
const outcome = await this.complete({
|
|
227
|
-
|
|
244
|
+
ownership,
|
|
245
|
+
user,
|
|
228
246
|
entityType,
|
|
229
247
|
params: result.authData || {},
|
|
230
248
|
credentialId,
|
|
@@ -261,14 +279,15 @@ class SubmitAuthorizationStep {
|
|
|
261
279
|
};
|
|
262
280
|
}
|
|
263
281
|
|
|
264
|
-
async complete({
|
|
282
|
+
async complete({ ownership, user, entityType, params, credentialId }) {
|
|
265
283
|
let callback;
|
|
266
284
|
try {
|
|
267
|
-
callback = await this.
|
|
268
|
-
|
|
285
|
+
({ result: callback } = await this.authorizeEntityForUser.execute({
|
|
286
|
+
user,
|
|
269
287
|
entityType,
|
|
270
|
-
params
|
|
271
|
-
|
|
288
|
+
data: params,
|
|
289
|
+
ownership,
|
|
290
|
+
}));
|
|
272
291
|
} catch (error) {
|
|
273
292
|
throw toAuthorizationError(error);
|
|
274
293
|
}
|
|
@@ -276,13 +295,18 @@ class SubmitAuthorizationStep {
|
|
|
276
295
|
this.moduleRepository.findEntityById(callback.entity_id),
|
|
277
296
|
this.credentialRepository.findCredentialById(callback.credential_id),
|
|
278
297
|
]);
|
|
298
|
+
// What the new entity is to this caller (§3.4), so a client need not list again.
|
|
299
|
+
const tenancy = entity
|
|
300
|
+
? describeRecord(entity, this.scopePolicy.accessContext(user), 'entity')
|
|
301
|
+
: null;
|
|
279
302
|
const outcome = {
|
|
280
303
|
status: 'complete',
|
|
281
|
-
entity,
|
|
304
|
+
entity: entity && tenancy ? { ...entity, tenancy } : entity,
|
|
282
305
|
credential: credential && {
|
|
283
306
|
...credential,
|
|
284
307
|
type: entityType,
|
|
285
308
|
entityIds: entity ? [String(entity.id)] : [],
|
|
309
|
+
...(tenancy && { tenancy }),
|
|
286
310
|
},
|
|
287
311
|
};
|
|
288
312
|
if (credentialId && String(callback.credential_id) !== String(credentialId)) {
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
2
|
const { ModuleConstants } = require('../ModuleConstants');
|
|
3
|
+
const { pickExistingEntity } = require('../scope-resolution');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What an entity belongs to when the caller says nothing: the person's own,
|
|
7
|
+
* outside any organization.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_TENANCY = Object.freeze({
|
|
10
|
+
scope: 'individual',
|
|
11
|
+
organizationId: null,
|
|
12
|
+
legacyOrgId: null,
|
|
13
|
+
});
|
|
3
14
|
const { getLogger } = require('../../logs');
|
|
4
15
|
|
|
5
16
|
const log = getLogger('frigg.modules.authorization_callback');
|
|
@@ -30,7 +41,17 @@ class ProcessAuthorizationCallback {
|
|
|
30
41
|
this.integrationRepository = integrationRepository;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} userId - the OWNER of what this creates (ADR-056 §3.2): the
|
|
46
|
+
* person for `individual`, the organization's user for `organization`.
|
|
47
|
+
* Not necessarily the caller; callers resolve it from the scope.
|
|
48
|
+
* @param {string} entityType
|
|
49
|
+
* @param {Object} params - what the module's authorization needs
|
|
50
|
+
* @param {{scope: string, organizationId: (string|null), legacyOrgId: (string|null)}} [tenancy]
|
|
51
|
+
* the entity's scope and organization (§3.6); default: the person's own,
|
|
52
|
+
* with no organization
|
|
53
|
+
*/
|
|
54
|
+
async execute(userId, entityType, params, tenancy = DEFAULT_TENANCY) {
|
|
34
55
|
log.debug('Authorization callback started', {
|
|
35
56
|
eventName: `${log.name}.started`,
|
|
36
57
|
userId,
|
|
@@ -57,10 +78,7 @@ class ProcessAuthorizationCallback {
|
|
|
57
78
|
userId,
|
|
58
79
|
entityType
|
|
59
80
|
);
|
|
60
|
-
const existingEntity =
|
|
61
|
-
existingEntities && existingEntities.length > 0
|
|
62
|
-
? existingEntities[0]
|
|
63
|
-
: null;
|
|
81
|
+
const existingEntity = pickExistingEntity(existingEntities, tenancy);
|
|
64
82
|
|
|
65
83
|
if (existingEntity) {
|
|
66
84
|
log.debug('Existing entity found', {
|
|
@@ -156,7 +174,8 @@ class ProcessAuthorizationCallback {
|
|
|
156
174
|
const persistedEntity = await this.findOrCreateEntity(
|
|
157
175
|
entityDetails,
|
|
158
176
|
entityType,
|
|
159
|
-
module.credential.id
|
|
177
|
+
module.credential.id,
|
|
178
|
+
tenancy
|
|
160
179
|
);
|
|
161
180
|
log.debug('Authorization callback completed', {
|
|
162
181
|
eventName: `${log.name}.completed`,
|
|
@@ -233,7 +252,12 @@ class ProcessAuthorizationCallback {
|
|
|
233
252
|
module.credential = persisted;
|
|
234
253
|
}
|
|
235
254
|
|
|
236
|
-
async findOrCreateEntity(
|
|
255
|
+
async findOrCreateEntity(
|
|
256
|
+
entityDetails,
|
|
257
|
+
moduleName,
|
|
258
|
+
credentialId,
|
|
259
|
+
tenancy = DEFAULT_TENANCY
|
|
260
|
+
) {
|
|
237
261
|
const { identifiers, details } = entityDetails;
|
|
238
262
|
|
|
239
263
|
// Support both 'user' and 'userId' field names from module definitions
|
|
@@ -247,11 +271,16 @@ class ProcessAuthorizationCallback {
|
|
|
247
271
|
);
|
|
248
272
|
}
|
|
249
273
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
274
|
+
// The same provider account in two organizations is two entities
|
|
275
|
+
// (ADR-056 §3.6), so the match is per organization.
|
|
276
|
+
const existingEntity = pickExistingEntity(
|
|
277
|
+
await this.moduleRepository.findEntities({
|
|
278
|
+
externalId: identifiers.externalId,
|
|
279
|
+
user: userId,
|
|
280
|
+
moduleName: moduleName,
|
|
281
|
+
}),
|
|
282
|
+
tenancy
|
|
283
|
+
);
|
|
255
284
|
|
|
256
285
|
if (existingEntity) {
|
|
257
286
|
// Repoint the entity's credentialId when re-auth produced a
|
|
@@ -282,12 +311,17 @@ class ProcessAuthorizationCallback {
|
|
|
282
311
|
return existingEntity;
|
|
283
312
|
}
|
|
284
313
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
314
|
+
// `organizationId` is not part of the entity data: that carries provider
|
|
315
|
+
// fields, and one that happens to be called organizationId is theirs.
|
|
316
|
+
return await this.moduleRepository.createEntity(
|
|
317
|
+
{
|
|
318
|
+
...identifiers,
|
|
319
|
+
...details,
|
|
320
|
+
moduleName: moduleName,
|
|
321
|
+
credential: credentialId,
|
|
322
|
+
},
|
|
323
|
+
{ organizationId: tenancy.organizationId ?? null }
|
|
324
|
+
);
|
|
291
325
|
}
|
|
292
326
|
}
|
|
293
327
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class RefreshEntityOptions {
|
|
4
5
|
/**
|
|
@@ -6,9 +7,11 @@ class RefreshEntityOptions {
|
|
|
6
7
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
7
8
|
* @param {} params.moduleDefinitions
|
|
8
9
|
*/
|
|
9
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
10
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
10
11
|
this.moduleRepository = moduleRepository;
|
|
11
12
|
this.moduleDefinitions = moduleDefinitions;
|
|
13
|
+
// Who may load an entity follows its scope (ADR-056 §3.6).
|
|
14
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -36,7 +39,7 @@ class RefreshEntityOptions {
|
|
|
36
39
|
|
|
37
40
|
// Validate entity ownership
|
|
38
41
|
const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
|
|
39
|
-
?
|
|
42
|
+
? this.scopePolicy.canUseEntity(userIdOrUser, entity)
|
|
40
43
|
: entity.userId?.toString() === userId?.toString();
|
|
41
44
|
|
|
42
45
|
if (!isOwned) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class TestModuleAuth {
|
|
4
5
|
/**
|
|
@@ -6,9 +7,11 @@ class TestModuleAuth {
|
|
|
6
7
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository - Repository for module data operations.
|
|
7
8
|
* @param {Array<Object>} params.moduleDefinitions - Array of module definitions.
|
|
8
9
|
*/
|
|
9
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
10
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
10
11
|
this.moduleRepository = moduleRepository;
|
|
11
12
|
this.moduleDefinitions = moduleDefinitions;
|
|
13
|
+
// Who may load an entity follows its scope (ADR-056 §3.6).
|
|
14
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -35,7 +38,7 @@ class TestModuleAuth {
|
|
|
35
38
|
|
|
36
39
|
// Validate entity ownership
|
|
37
40
|
const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
|
|
38
|
-
?
|
|
41
|
+
? this.scopePolicy.canUseEntity(userIdOrUser, entity)
|
|
39
42
|
: entity.userId?.toString() === userId?.toString();
|
|
40
43
|
|
|
41
44
|
if (!isOwned) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.683.f45da33.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.683.f45da33.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.683.f45da33.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.683.f45da33.0",
|
|
54
54
|
"@prisma/client": "^6.19.3",
|
|
55
55
|
"@types/lodash": "4.17.15",
|
|
56
56
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -90,5 +90,5 @@
|
|
|
90
90
|
"publishConfig": {
|
|
91
91
|
"access": "public"
|
|
92
92
|
},
|
|
93
|
-
"gitHead": "
|
|
93
|
+
"gitHead": "f45da337e62262988666a166b3d721020dae9161"
|
|
94
94
|
}
|
|
@@ -134,6 +134,14 @@ model Entity {
|
|
|
134
134
|
moduleName String?
|
|
135
135
|
externalId String?
|
|
136
136
|
|
|
137
|
+
// Organization this entity was connected in (ADR-056 §3.6): the active
|
|
138
|
+
// organization for `individual`, the owner for `organization`, null for
|
|
139
|
+
// `global`. Null on an `individual` entity is also what every row written
|
|
140
|
+
// before this column existed has, and what `individualAcross: 'all'` writes.
|
|
141
|
+
// Deliberately not a relation: a deleted organization must leave its rows
|
|
142
|
+
// invisible, not turn them into "unscoped" ones (SetNull would do that).
|
|
143
|
+
organizationId String? @db.ObjectId
|
|
144
|
+
|
|
137
145
|
data Json @default("{}")
|
|
138
146
|
|
|
139
147
|
createdAt DateTime @default(now())
|
|
@@ -150,6 +158,7 @@ model Entity {
|
|
|
150
158
|
associationObjects AssociationObject[]
|
|
151
159
|
|
|
152
160
|
@@index([userId])
|
|
161
|
+
@@index([userId, organizationId])
|
|
153
162
|
@@index([externalId])
|
|
154
163
|
@@index([moduleName])
|
|
155
164
|
@@index([credentialId])
|
|
@@ -167,6 +176,11 @@ model Integration {
|
|
|
167
176
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
168
177
|
status IntegrationStatus @default(IN_CREATION)
|
|
169
178
|
|
|
179
|
+
// Organization this integration was created in (ADR-056 §3.6): the active
|
|
180
|
+
// organization at create time. Null for rows that predate the column. Not a
|
|
181
|
+
// relation, for the same reason as Entity.organizationId.
|
|
182
|
+
organizationId String? @db.ObjectId
|
|
183
|
+
|
|
170
184
|
// Configuration and version
|
|
171
185
|
config Json? // Integration configuration object
|
|
172
186
|
version String?
|
|
@@ -191,6 +205,7 @@ model Integration {
|
|
|
191
205
|
processes Process[]
|
|
192
206
|
|
|
193
207
|
@@index([userId])
|
|
208
|
+
@@index([userId, organizationId])
|
|
194
209
|
@@index([status])
|
|
195
210
|
@@map("Integration")
|
|
196
211
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Entity and Integration carry the organization they were created in (ADR-056 §3.6).
|
|
2
|
+
-- Nullable, no default and no backfill: a row written before this migration keeps
|
|
3
|
+
-- NULL, and the domain (modules/entity-scope.js) reads NULL as "predates scoping"
|
|
4
|
+
-- (docs/guides/entity-scopes.md). No scope column: the owner's user type says it.
|
|
5
|
+
-- Deliberately no foreign key: a deleted organization must leave its rows
|
|
6
|
+
-- invisible, and ON DELETE SET NULL would turn them into unscoped ones.
|
|
7
|
+
--
|
|
8
|
+
-- Release note: the two CREATE INDEX statements below are plain (not
|
|
9
|
+
-- CONCURRENTLY, which Prisma migrations cannot run inside a transaction). Each
|
|
10
|
+
-- takes a SHARE lock on its table while it builds, so writes to "Entity" and
|
|
11
|
+
-- "Integration" wait for it. Both tables are small in a typical Frigg
|
|
12
|
+
-- deployment; on a very large one, create the indexes by hand first with
|
|
13
|
+
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS under the same names, then deploy.
|
|
14
|
+
|
|
15
|
+
-- AlterTable
|
|
16
|
+
ALTER TABLE "Entity" ADD COLUMN "organizationId" INTEGER;
|
|
17
|
+
|
|
18
|
+
-- AlterTable
|
|
19
|
+
ALTER TABLE "Integration" ADD COLUMN "organizationId" INTEGER;
|
|
20
|
+
|
|
21
|
+
-- CreateIndex
|
|
22
|
+
CREATE INDEX "Entity_userId_organizationId_idx" ON "Entity"("userId", "organizationId");
|
|
23
|
+
|
|
24
|
+
-- CreateIndex
|
|
25
|
+
CREATE INDEX "Integration_userId_organizationId_idx" ON "Integration"("userId", "organizationId");
|
|
@@ -131,6 +131,14 @@ model Entity {
|
|
|
131
131
|
moduleName String?
|
|
132
132
|
externalId String?
|
|
133
133
|
|
|
134
|
+
// Organization this entity was connected in (ADR-056 §3.6): the active
|
|
135
|
+
// organization for `individual`, the owner for `organization`, null for
|
|
136
|
+
// `global`. Null on an `individual` entity is also what every row written
|
|
137
|
+
// before this column existed has, and what `individualAcross: 'all'` writes.
|
|
138
|
+
// Deliberately not a relation: a deleted organization must leave its rows
|
|
139
|
+
// invisible, not turn them into "unscoped" ones (SetNull would do that).
|
|
140
|
+
organizationId Int?
|
|
141
|
+
|
|
134
142
|
data Json @default("{}")
|
|
135
143
|
|
|
136
144
|
createdAt DateTime @default(now())
|
|
@@ -144,6 +152,7 @@ model Entity {
|
|
|
144
152
|
associationObjects AssociationObject[]
|
|
145
153
|
|
|
146
154
|
@@index([userId])
|
|
155
|
+
@@index([userId, organizationId])
|
|
147
156
|
@@index([externalId])
|
|
148
157
|
@@index([moduleName])
|
|
149
158
|
@@index([credentialId])
|
|
@@ -160,6 +169,11 @@ model Integration {
|
|
|
160
169
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
161
170
|
status IntegrationStatus @default(IN_CREATION)
|
|
162
171
|
|
|
172
|
+
// Organization this integration was created in (ADR-056 §3.6): the active
|
|
173
|
+
// organization at create time. Null for rows that predate the column. Not a
|
|
174
|
+
// relation, for the same reason as Entity.organizationId.
|
|
175
|
+
organizationId Int?
|
|
176
|
+
|
|
163
177
|
// Configuration and version
|
|
164
178
|
config Json? // Integration configuration object
|
|
165
179
|
version String?
|
|
@@ -183,6 +197,7 @@ model Integration {
|
|
|
183
197
|
processes Process[]
|
|
184
198
|
|
|
185
199
|
@@index([userId])
|
|
200
|
+
@@index([userId, organizationId])
|
|
186
201
|
@@index([status])
|
|
187
202
|
}
|
|
188
203
|
|
package/types/index.d.ts
CHANGED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
declare module "@friggframework/core" {
|
|
2
|
+
/** Where an entity type comes from: the app's `entities` map or an integration binding it. */
|
|
3
|
+
export type EntityTypeSource =
|
|
4
|
+
| { kind: "app" }
|
|
5
|
+
| { kind: "integration"; name: string };
|
|
6
|
+
|
|
7
|
+
export type EntityScope = "individual" | "organization" | "global";
|
|
8
|
+
|
|
9
|
+
/** App-level settings carried from the `entities` entry; empty for an inferred-only type. */
|
|
10
|
+
export interface EntityTypeSettings {
|
|
11
|
+
scope?: EntityScope | { default?: EntityScope; allow?: EntityScope[] };
|
|
12
|
+
proxy?: Record<string, unknown>;
|
|
13
|
+
display?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EntityTypeEntry {
|
|
17
|
+
/** The API module definition. */
|
|
18
|
+
definition: any;
|
|
19
|
+
/** Frozen. `{ kind: 'app' }` first when registered, then each binding integration. */
|
|
20
|
+
sources: readonly EntityTypeSource[];
|
|
21
|
+
/** Frozen. */
|
|
22
|
+
settings: Readonly<EntityTypeSettings>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Entity type name (the definition's moduleName) to its entry. */
|
|
26
|
+
export type EntityTypeRegistry = Map<string, EntityTypeEntry>;
|
|
27
|
+
|
|
28
|
+
/** One `appDefinition.entities` entry. The key must equal the definition's moduleName (and getName() when defined). */
|
|
29
|
+
export interface EntityRegistration extends EntityTypeSettings {
|
|
30
|
+
definition: any;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AppDefinitionEntities {
|
|
34
|
+
/** false turns inference off: every module an integration binds must be registered. Default true. */
|
|
35
|
+
$infer?: boolean;
|
|
36
|
+
[entityType: string]: EntityRegistration | boolean | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One level's setting: a scope, or a default plus the scopes a request may
|
|
41
|
+
* choose from (ADR-056 §3.1). `global` is never chosen by a tenant request.
|
|
42
|
+
*/
|
|
43
|
+
export type ScopeSetting =
|
|
44
|
+
| EntityScope
|
|
45
|
+
| { default?: EntityScope; allow?: EntityScope[] };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `appDefinition.ownership` (ADR-056 §3, §3.6). Resolution order for a new
|
|
49
|
+
* connection: request, binding, integration, entity type (`entities[name].scope`),
|
|
50
|
+
* this `scope`, then `organization` for an organization-primary app and
|
|
51
|
+
* `individual` otherwise.
|
|
52
|
+
*/
|
|
53
|
+
export interface AppOwnership {
|
|
54
|
+
scope?: ScopeSetting;
|
|
55
|
+
/**
|
|
56
|
+
* `'organization'` (default): an individual entity belongs to the organization
|
|
57
|
+
* it was connected in. `'all'`: one connection per person across every
|
|
58
|
+
* organization, stored with no `organizationId`.
|
|
59
|
+
*/
|
|
60
|
+
individualAcross?: "organization" | "all";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `Definition.ownership` on an integration (ADR-056 §3.1, §3.3). */
|
|
64
|
+
export interface IntegrationOwnership {
|
|
65
|
+
/** Scope every binding of this integration accepts unless it sets its own. */
|
|
66
|
+
scope?: ScopeSetting;
|
|
67
|
+
/** `'derive'` (default): organization when every entity it uses is organization-scoped. */
|
|
68
|
+
owner?: "derive" | "individual" | "organization";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AppDefinition {
|
|
72
|
+
integrations: any[];
|
|
73
|
+
/** App-level entity type registry (ADR-059). */
|
|
74
|
+
entities?: AppDefinitionEntities;
|
|
75
|
+
/** Who owns what a person connects (ADR-056). */
|
|
76
|
+
ownership?: AppOwnership;
|
|
77
|
+
[key: string]: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whose a record is and what the caller may do with it (ADR-056 §3.4). */
|
|
81
|
+
export interface RecordTenancy {
|
|
82
|
+
scope: Exclude<EntityScope, "global">;
|
|
83
|
+
owner: { type: Exclude<EntityScope, "global">; id: string };
|
|
84
|
+
/** Null for an individual entity connected outside an organization, or under `individualAcross: 'all'`. */
|
|
85
|
+
organizationId: string | null;
|
|
86
|
+
/** `manage`: create, reauthorize, delete. `use`: bind it, proxy through it. `view`: metadata only. */
|
|
87
|
+
access: "manage" | "use" | "view";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Which scope a connection gets, and from which level of the order (ADR-056 §3.1). */
|
|
91
|
+
export interface ResolvedScope {
|
|
92
|
+
scope: EntityScope;
|
|
93
|
+
/** Where `scope` came from. `request` when the request chose within the allowed list. */
|
|
94
|
+
source: "request" | "binding" | "integration" | "entityType" | "app" | "default";
|
|
95
|
+
/** The level that decided what a request may choose from. */
|
|
96
|
+
decidedBy: "binding" | "integration" | "entityType" | "app" | "default";
|
|
97
|
+
allow: EntityScope[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* First match wins, each level overriding the ones below it. A request may
|
|
102
|
+
* choose only from the deciding level's `allow`; `global` never comes from a
|
|
103
|
+
* tenant request. Throws a Boom 400/403 for a request scope that is not allowed.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveScope(levels: {
|
|
106
|
+
request?: EntityScope;
|
|
107
|
+
binding?: ScopeSetting;
|
|
108
|
+
integration?: ScopeSetting;
|
|
109
|
+
entityType?: ScopeSetting;
|
|
110
|
+
app?: ScopeSetting;
|
|
111
|
+
primary?: "individual" | "organization";
|
|
112
|
+
}): ResolvedScope;
|
|
113
|
+
|
|
114
|
+
/** Every binding of every integration with its resolved scope (for tooling that wants to show the chain). */
|
|
115
|
+
export function listBindingScopes(
|
|
116
|
+
appDefinition: Pick<AppDefinition, "integrations" | "ownership">,
|
|
117
|
+
entityTypeRegistry: EntityTypeRegistry,
|
|
118
|
+
primary?: "individual" | "organization"
|
|
119
|
+
): Array<{
|
|
120
|
+
integration: string;
|
|
121
|
+
binding: string;
|
|
122
|
+
moduleName: string | null;
|
|
123
|
+
scope: EntityScope;
|
|
124
|
+
source: ResolvedScope["source"];
|
|
125
|
+
owner: "derive" | "individual" | "organization";
|
|
126
|
+
}>;
|
|
127
|
+
|
|
128
|
+
/** Invalid ownership or scope setting, thrown at app-definition load. `code` is always `OWNERSHIP_CONFIG_INVALID`. */
|
|
129
|
+
export class OwnershipConfigError extends Error {
|
|
130
|
+
code: "OWNERSHIP_CONFIG_INVALID";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Builds the registry from `entities` plus the modules integrations bind.
|
|
135
|
+
* Throws EntityRegistryError on an invalid registry.
|
|
136
|
+
*/
|
|
137
|
+
export function getEntityTypeRegistry(
|
|
138
|
+
appDefinition?: Pick<AppDefinition, "entities" | "integrations">
|
|
139
|
+
): EntityTypeRegistry;
|
|
140
|
+
|
|
141
|
+
/** Invalid `entities` registry. `code` is always `ENTITY_REGISTRY_INVALID`. */
|
|
142
|
+
export class EntityRegistryError extends Error {
|
|
143
|
+
code: "ENTITY_REGISTRY_INVALID";
|
|
144
|
+
}
|
|
145
|
+
}
|
package/types/user/index.d.ts
CHANGED
|
@@ -62,6 +62,13 @@ declare module "@friggframework/core/user" {
|
|
|
62
62
|
* at startup. `strictMembership` wins when both are set.
|
|
63
63
|
*/
|
|
64
64
|
strictUserValidation?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Roles (the adopter token's role claim) that may manage organization-level
|
|
67
|
+
* things (ADR-056 §3). Default `['ADMIN']`; a token with no role claim is
|
|
68
|
+
* `MEMBER`, never admin; a shared-secret (`x-frigg` header) caller needs no
|
|
69
|
+
* role. Compared case-insensitively. Validated at load.
|
|
70
|
+
*/
|
|
71
|
+
orgAdminRoles?: string[];
|
|
65
72
|
authModes?: UserAuthModes;
|
|
66
73
|
jwtConfig?: AdopterJwtConfig;
|
|
67
74
|
}
|