@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
|
@@ -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,30 @@ 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
|
+
* @param {string} [params.fixedScope] - re-authorization keeps the scope the
|
|
170
|
+
* credential already has, whatever the config says now.
|
|
171
|
+
*/
|
|
172
|
+
async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope, fixedScope }) {
|
|
162
173
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
163
174
|
throw Boom.badRequest('data must be an object', {
|
|
164
175
|
code: 'VALIDATION_ERROR',
|
|
@@ -166,7 +177,16 @@ class SubmitAuthorizationStep {
|
|
|
166
177
|
});
|
|
167
178
|
}
|
|
168
179
|
const definition = findDefinition(this.moduleDefinitions, entityType);
|
|
180
|
+
// `userId` binds a session to the caller and is what GET /authorize
|
|
181
|
+
// computes too. The owner of the entity this creates is decided by
|
|
182
|
+
// scope (§3.2), not by user.primary, and is `ownership.ownerId`.
|
|
169
183
|
const userId = user.getId();
|
|
184
|
+
const ownership = this.authorizeEntityForUser.resolveOwnership({
|
|
185
|
+
user,
|
|
186
|
+
entityType,
|
|
187
|
+
scope,
|
|
188
|
+
fixedScope,
|
|
189
|
+
});
|
|
170
190
|
|
|
171
191
|
if (!isMultiStep(definition)) {
|
|
172
192
|
if (step !== 1) {
|
|
@@ -174,7 +194,7 @@ class SubmitAuthorizationStep {
|
|
|
174
194
|
code: 'INVALID_STEP',
|
|
175
195
|
});
|
|
176
196
|
}
|
|
177
|
-
return this.complete({
|
|
197
|
+
return this.complete({ ownership, user, entityType, params: data, credentialId });
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
const totalSteps = getAuthStepCount(definition);
|
|
@@ -205,7 +225,7 @@ class SubmitAuthorizationStep {
|
|
|
205
225
|
);
|
|
206
226
|
}
|
|
207
227
|
|
|
208
|
-
const module = this.createModule({ userId, definition });
|
|
228
|
+
const module = this.createModule({ userId: ownership.ownerId, definition });
|
|
209
229
|
let result;
|
|
210
230
|
try {
|
|
211
231
|
result = await definition.processAuthorizationStep(
|
|
@@ -224,7 +244,8 @@ class SubmitAuthorizationStep {
|
|
|
224
244
|
session.markComplete();
|
|
225
245
|
await this.sessions.update(session);
|
|
226
246
|
const outcome = await this.complete({
|
|
227
|
-
|
|
247
|
+
ownership,
|
|
248
|
+
user,
|
|
228
249
|
entityType,
|
|
229
250
|
params: result.authData || {},
|
|
230
251
|
credentialId,
|
|
@@ -261,14 +282,15 @@ class SubmitAuthorizationStep {
|
|
|
261
282
|
};
|
|
262
283
|
}
|
|
263
284
|
|
|
264
|
-
async complete({
|
|
285
|
+
async complete({ ownership, user, entityType, params, credentialId }) {
|
|
265
286
|
let callback;
|
|
266
287
|
try {
|
|
267
|
-
callback = await this.
|
|
268
|
-
|
|
288
|
+
({ result: callback } = await this.authorizeEntityForUser.execute({
|
|
289
|
+
user,
|
|
269
290
|
entityType,
|
|
270
|
-
params
|
|
271
|
-
|
|
291
|
+
data: params,
|
|
292
|
+
ownership,
|
|
293
|
+
}));
|
|
272
294
|
} catch (error) {
|
|
273
295
|
throw toAuthorizationError(error);
|
|
274
296
|
}
|
|
@@ -276,13 +298,18 @@ class SubmitAuthorizationStep {
|
|
|
276
298
|
this.moduleRepository.findEntityById(callback.entity_id),
|
|
277
299
|
this.credentialRepository.findCredentialById(callback.credential_id),
|
|
278
300
|
]);
|
|
301
|
+
// What the new entity is to this caller (§3.4), so a client need not list again.
|
|
302
|
+
const tenancy = entity
|
|
303
|
+
? describeRecord(entity, this.scopePolicy.accessContext(user), 'entity')
|
|
304
|
+
: null;
|
|
279
305
|
const outcome = {
|
|
280
306
|
status: 'complete',
|
|
281
|
-
entity,
|
|
307
|
+
entity: entity && tenancy ? { ...entity, tenancy } : entity,
|
|
282
308
|
credential: credential && {
|
|
283
309
|
...credential,
|
|
284
310
|
type: entityType,
|
|
285
311
|
entityIds: entity ? [String(entity.id)] : [],
|
|
312
|
+
...(tenancy && { tenancy }),
|
|
286
313
|
},
|
|
287
314
|
};
|
|
288
315
|
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) {
|
|
@@ -58,8 +61,11 @@ class RefreshEntityOptions {
|
|
|
58
61
|
);
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
65
|
+
// organization's entity, and a token refresh during this call must
|
|
66
|
+
// update the owner's credential, not create one under the caller.
|
|
61
67
|
const module = new Module({
|
|
62
|
-
userId,
|
|
68
|
+
userId: entity.userId ?? userId,
|
|
63
69
|
entity,
|
|
64
70
|
definition: moduleDefinition,
|
|
65
71
|
});
|
|
@@ -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) {
|
|
@@ -57,8 +60,11 @@ class TestModuleAuth {
|
|
|
57
60
|
);
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
64
|
+
// organization's entity, and a token refresh during this call must
|
|
65
|
+
// update the owner's credential, not create one under the caller.
|
|
60
66
|
const module = new Module({
|
|
61
|
-
userId,
|
|
67
|
+
userId: entity.userId ?? userId,
|
|
62
68
|
entity,
|
|
63
69
|
definition: moduleDefinition,
|
|
64
70
|
});
|
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.686.87e9875.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"express-async-handler": "^1.2.0",
|
|
29
29
|
"form-data": "^4.0.6",
|
|
30
30
|
"fs-extra": "^11.2.0",
|
|
31
|
+
"jose": "^5.10.0",
|
|
31
32
|
"lodash": "4.18.1",
|
|
32
33
|
"lodash.get": "^4.4.2",
|
|
33
34
|
"node-fetch": "^2.6.7",
|
|
@@ -47,9 +48,9 @@
|
|
|
47
48
|
}
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
52
|
-
"@friggframework/test": "2.0.0--canary.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.686.87e9875.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.686.87e9875.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.686.87e9875.0",
|
|
53
54
|
"@prisma/client": "^6.19.3",
|
|
54
55
|
"@types/lodash": "4.17.15",
|
|
55
56
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -89,5 +90,5 @@
|
|
|
89
90
|
"publishConfig": {
|
|
90
91
|
"access": "public"
|
|
91
92
|
},
|
|
92
|
-
"gitHead": "
|
|
93
|
+
"gitHead": "87e9875d4dba36bf3bb90400e862d5fb9db17ae1"
|
|
93
94
|
}
|
|
@@ -56,6 +56,14 @@ model User {
|
|
|
56
56
|
integrations Integration[]
|
|
57
57
|
processes Process[]
|
|
58
58
|
|
|
59
|
+
// Organization memberships (ADR-056 §3.6). `organizationId` above is the legacy
|
|
60
|
+
// single-org pointer: set on a person's first link, never moved afterwards, and
|
|
61
|
+
// read as an implicit membership when no row exists for it.
|
|
62
|
+
// As an individual: the organizations this person belongs to.
|
|
63
|
+
memberships OrganizationMembership[] @relation("IndividualMemberships")
|
|
64
|
+
// As an organization: the people who belong to it.
|
|
65
|
+
orgMembers OrganizationMembership[] @relation("OrganizationMemberships")
|
|
66
|
+
|
|
59
67
|
@@unique([username, appUserId])
|
|
60
68
|
@@index([type])
|
|
61
69
|
@@index([appUserId])
|
|
@@ -126,6 +134,14 @@ model Entity {
|
|
|
126
134
|
moduleName String?
|
|
127
135
|
externalId String?
|
|
128
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
|
+
|
|
129
145
|
data Json @default("{}")
|
|
130
146
|
|
|
131
147
|
createdAt DateTime @default(now())
|
|
@@ -142,6 +158,7 @@ model Entity {
|
|
|
142
158
|
associationObjects AssociationObject[]
|
|
143
159
|
|
|
144
160
|
@@index([userId])
|
|
161
|
+
@@index([userId, organizationId])
|
|
145
162
|
@@index([externalId])
|
|
146
163
|
@@index([moduleName])
|
|
147
164
|
@@index([credentialId])
|
|
@@ -159,6 +176,11 @@ model Integration {
|
|
|
159
176
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
160
177
|
status IntegrationStatus @default(IN_CREATION)
|
|
161
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
|
+
|
|
162
184
|
// Configuration and version
|
|
163
185
|
config Json? // Integration configuration object
|
|
164
186
|
version String?
|
|
@@ -183,6 +205,7 @@ model Integration {
|
|
|
183
205
|
processes Process[]
|
|
184
206
|
|
|
185
207
|
@@index([userId])
|
|
208
|
+
@@index([userId, organizationId])
|
|
186
209
|
@@index([status])
|
|
187
210
|
@@map("Integration")
|
|
188
211
|
}
|
|
@@ -480,3 +503,45 @@ model AuthorizationSession {
|
|
|
480
503
|
@@index([expiresAt])
|
|
481
504
|
@@map("AuthorizationSession")
|
|
482
505
|
}
|
|
506
|
+
|
|
507
|
+
// ============================================================================
|
|
508
|
+
// ORGANIZATION MEMBERSHIP (ADR-056 §3.6)
|
|
509
|
+
// ============================================================================
|
|
510
|
+
|
|
511
|
+
/// One person's membership of one organization. Unique on the pair, so adding a
|
|
512
|
+
/// membership is an idempotent upsert. `orgRole` is the organization role the
|
|
513
|
+
/// adopter token carried when the membership was created (MEMBER when it named none). NoAction on delete
|
|
514
|
+
/// because MongoDB gives no referential integrity: delete a user's memberships
|
|
515
|
+
/// before the user.
|
|
516
|
+
model OrganizationMembership {
|
|
517
|
+
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
518
|
+
individualUserId String @db.ObjectId
|
|
519
|
+
organizationUserId String @db.ObjectId
|
|
520
|
+
orgRole String @default("MEMBER")
|
|
521
|
+
createdAt DateTime @default(now())
|
|
522
|
+
|
|
523
|
+
individualUser User @relation("IndividualMemberships", fields: [individualUserId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
|
524
|
+
organizationUser User @relation("OrganizationMemberships", fields: [organizationUserId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
|
525
|
+
|
|
526
|
+
@@unique([individualUserId, organizationUserId])
|
|
527
|
+
@@index([organizationUserId])
|
|
528
|
+
@@map("OrganizationMembership")
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/// One-time scheduled jobs for poll-and-dispatch scheduling pattern.
|
|
532
|
+
/// Used by providers without native one-time scheduling APIs (e.g., Netlify).
|
|
533
|
+
/// A cron function queries for due jobs and dispatches them to a queue.
|
|
534
|
+
model ScheduledJob {
|
|
535
|
+
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
536
|
+
scheduleName String @unique
|
|
537
|
+
scheduledAt DateTime
|
|
538
|
+
queueResourceId String
|
|
539
|
+
payload Json?
|
|
540
|
+
state String @default("PENDING")
|
|
541
|
+
|
|
542
|
+
createdAt DateTime @default(now())
|
|
543
|
+
updatedAt DateTime @updatedAt
|
|
544
|
+
|
|
545
|
+
@@index([state, scheduledAt])
|
|
546
|
+
@@map("ScheduledJob")
|
|
547
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- CreateTable
|
|
2
|
+
-- Organization memberships (ADR-056 §3.6). One row per person per organization.
|
|
3
|
+
-- "User"."organizationId" stays as the legacy single-org pointer. It is NOT
|
|
4
|
+
-- backfilled into this table: the domain (user/membership.js) reads it as an
|
|
5
|
+
-- implicit membership when no row exists (see docs/guides/organization-memberships.md).
|
|
6
|
+
CREATE TABLE "OrganizationMembership" (
|
|
7
|
+
"id" SERIAL NOT NULL,
|
|
8
|
+
"individualUserId" INTEGER NOT NULL,
|
|
9
|
+
"organizationUserId" INTEGER NOT NULL,
|
|
10
|
+
"orgRole" TEXT NOT NULL DEFAULT 'MEMBER',
|
|
11
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
12
|
+
|
|
13
|
+
CONSTRAINT "OrganizationMembership_pkey" PRIMARY KEY ("id")
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
-- CreateIndex
|
|
17
|
+
CREATE INDEX "OrganizationMembership_organizationUserId_idx" ON "OrganizationMembership"("organizationUserId");
|
|
18
|
+
|
|
19
|
+
-- CreateIndex
|
|
20
|
+
CREATE UNIQUE INDEX "OrganizationMembership_individualUserId_organizationUserId_key" ON "OrganizationMembership"("individualUserId", "organizationUserId");
|
|
21
|
+
|
|
22
|
+
-- AddForeignKey
|
|
23
|
+
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_individualUserId_fkey" FOREIGN KEY ("individualUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
|
24
|
+
|
|
25
|
+
-- AddForeignKey
|
|
26
|
+
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_organizationUserId_fkey" FOREIGN KEY ("organizationUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
|
@@ -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");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- One-time scheduled jobs for the poll-and-dispatch scheduler
|
|
2
|
+
-- (NetlifySchedulerAdapter, friggframework/frigg#545): a cron function reads
|
|
3
|
+
-- PENDING rows that are due and dispatches them to a queue.
|
|
4
|
+
--
|
|
5
|
+
-- IF NOT EXISTS on purpose: #545 shipped this model in the Prisma schema with
|
|
6
|
+
-- no migration, so an app that ran its canary may already have the table from
|
|
7
|
+
-- `prisma db push`. On such a database this migration is a no-op; on a fresh
|
|
8
|
+
-- one it is the SQL `prisma migrate diff` generates.
|
|
9
|
+
|
|
10
|
+
-- CreateTable
|
|
11
|
+
CREATE TABLE IF NOT EXISTS "ScheduledJob" (
|
|
12
|
+
"id" SERIAL NOT NULL,
|
|
13
|
+
"scheduleName" TEXT NOT NULL,
|
|
14
|
+
"scheduledAt" TIMESTAMP(3) NOT NULL,
|
|
15
|
+
"queueResourceId" TEXT NOT NULL,
|
|
16
|
+
"payload" JSONB,
|
|
17
|
+
"state" TEXT NOT NULL DEFAULT 'PENDING',
|
|
18
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
19
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
20
|
+
|
|
21
|
+
CONSTRAINT "ScheduledJob_pkey" PRIMARY KEY ("id")
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
-- CreateIndex
|
|
25
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "ScheduledJob_scheduleName_key" ON "ScheduledJob"("scheduleName");
|
|
26
|
+
|
|
27
|
+
-- CreateIndex
|
|
28
|
+
CREATE INDEX IF NOT EXISTS "ScheduledJob_state_scheduledAt_idx" ON "ScheduledJob"("state", "scheduledAt");
|
|
@@ -56,6 +56,14 @@ model User {
|
|
|
56
56
|
integrations Integration[]
|
|
57
57
|
processes Process[]
|
|
58
58
|
|
|
59
|
+
// Organization memberships (ADR-056 §3.6). `organizationId` above is the legacy
|
|
60
|
+
// single-org pointer: set on a person's first link, never moved afterwards, and
|
|
61
|
+
// read as an implicit membership when no row exists for it.
|
|
62
|
+
// As an individual: the organizations this person belongs to.
|
|
63
|
+
memberships OrganizationMembership[] @relation("IndividualMemberships")
|
|
64
|
+
// As an organization: the people who belong to it.
|
|
65
|
+
orgMembers OrganizationMembership[] @relation("OrganizationMemberships")
|
|
66
|
+
|
|
59
67
|
@@unique([username, appUserId])
|
|
60
68
|
@@index([type])
|
|
61
69
|
@@index([appUserId])
|
|
@@ -123,6 +131,14 @@ model Entity {
|
|
|
123
131
|
moduleName String?
|
|
124
132
|
externalId String?
|
|
125
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
|
+
|
|
126
142
|
data Json @default("{}")
|
|
127
143
|
|
|
128
144
|
createdAt DateTime @default(now())
|
|
@@ -136,6 +152,7 @@ model Entity {
|
|
|
136
152
|
associationObjects AssociationObject[]
|
|
137
153
|
|
|
138
154
|
@@index([userId])
|
|
155
|
+
@@index([userId, organizationId])
|
|
139
156
|
@@index([externalId])
|
|
140
157
|
@@index([moduleName])
|
|
141
158
|
@@index([credentialId])
|
|
@@ -152,6 +169,11 @@ model Integration {
|
|
|
152
169
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
153
170
|
status IntegrationStatus @default(IN_CREATION)
|
|
154
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
|
+
|
|
155
177
|
// Configuration and version
|
|
156
178
|
config Json? // Integration configuration object
|
|
157
179
|
version String?
|
|
@@ -175,6 +197,7 @@ model Integration {
|
|
|
175
197
|
processes Process[]
|
|
176
198
|
|
|
177
199
|
@@index([userId])
|
|
200
|
+
@@index([userId, organizationId])
|
|
178
201
|
@@index([status])
|
|
179
202
|
}
|
|
180
203
|
|
|
@@ -460,3 +483,41 @@ model AuthorizationSession {
|
|
|
460
483
|
@@index([userId, entityType])
|
|
461
484
|
@@index([expiresAt])
|
|
462
485
|
}
|
|
486
|
+
|
|
487
|
+
// ============================================================================
|
|
488
|
+
// ORGANIZATION MEMBERSHIP (ADR-056 §3.6)
|
|
489
|
+
// ============================================================================
|
|
490
|
+
|
|
491
|
+
/// One person's membership of one organization. Unique on the pair, so adding a
|
|
492
|
+
/// membership is an idempotent upsert. `orgRole` is the organization role the
|
|
493
|
+
/// adopter token carried when the membership was created (MEMBER when it named none).
|
|
494
|
+
model OrganizationMembership {
|
|
495
|
+
id Int @id @default(autoincrement())
|
|
496
|
+
individualUserId Int
|
|
497
|
+
organizationUserId Int
|
|
498
|
+
orgRole String @default("MEMBER")
|
|
499
|
+
createdAt DateTime @default(now())
|
|
500
|
+
|
|
501
|
+
individualUser User @relation("IndividualMemberships", fields: [individualUserId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
502
|
+
organizationUser User @relation("OrganizationMemberships", fields: [organizationUserId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
503
|
+
|
|
504
|
+
@@unique([individualUserId, organizationUserId])
|
|
505
|
+
@@index([organizationUserId])
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/// One-time scheduled jobs for poll-and-dispatch scheduling pattern.
|
|
509
|
+
/// Used by providers without native one-time scheduling APIs (e.g., Netlify).
|
|
510
|
+
/// A cron function queries for due jobs and dispatches them to a queue.
|
|
511
|
+
model ScheduledJob {
|
|
512
|
+
id Int @id @default(autoincrement())
|
|
513
|
+
scheduleName String @unique
|
|
514
|
+
scheduledAt DateTime
|
|
515
|
+
queueResourceId String
|
|
516
|
+
payload Json?
|
|
517
|
+
state String @default("PENDING")
|
|
518
|
+
|
|
519
|
+
createdAt DateTime @default(now())
|
|
520
|
+
updatedAt DateTime @updatedAt
|
|
521
|
+
|
|
522
|
+
@@index([state, scheduledAt])
|
|
523
|
+
}
|