@friggframework/core 2.0.0--canary.680.daae24e.0 → 2.0.0--canary.686.e94de4d.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +0 -11
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- 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/README.md +0 -2
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +99 -47
- package/database/encryption/field-encryption-service.js +5 -69
- 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 +187 -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 +103 -12
- package/handlers/routers/auth.js +40 -12
- package/handlers/routers/health.js +4 -2
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/index.js +63 -1
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +5 -0
- 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/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +7 -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 +44 -1
- package/management-api/management-api-config.js +71 -2
- package/management-api/meta-router.js +21 -2
- package/management-api/openapi/components.js +40 -0
- package/management-api/openapi/v2-operations.js +16 -4
- package/management-api/route-registry.js +123 -6
- 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 +53 -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-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +34 -7
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +40 -13
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- 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 +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +139 -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
- package/database/encryption/test-support/fake-ciphertext.js +0 -15
|
@@ -2,6 +2,8 @@ const Boom = require('@hapi/boom');
|
|
|
2
2
|
const { getLogger } = require('../../logs');
|
|
3
3
|
const { getTelemetry } = require('../../telemetry/telemetry-runtime');
|
|
4
4
|
const { ProxyRequestError } = require('../proxy/proxy-errors');
|
|
5
|
+
const { SCOPES } = require('../scope-resolution');
|
|
6
|
+
const { grantsUse } = require('../scope-visibility');
|
|
5
7
|
const { normalizeProxyPath, matchAllowRule } = require('../proxy/proxy-path');
|
|
6
8
|
const {
|
|
7
9
|
validateProxyRequest,
|
|
@@ -55,9 +57,11 @@ const authHeaderNamesOf = (api) =>
|
|
|
55
57
|
* through its module with the stored credential, inside the limits the app
|
|
56
58
|
* and the module set.
|
|
57
59
|
*
|
|
58
|
-
* Order of checks: request shape and size, path rules, entity
|
|
59
|
-
* (404),
|
|
60
|
-
*
|
|
60
|
+
* Order of checks: request shape and size, path rules, entity visibility in
|
|
61
|
+
* the active organization (404), `use` access (403), the entity's scope
|
|
62
|
+
* against `managementApi.proxy.scopes` (403), the module's allow-list (403,
|
|
63
|
+
* deny by default), credential readiness (401), then the call through
|
|
64
|
+
* Requester._proxyRequest. Every call,
|
|
61
65
|
* allowed or denied, writes one audit record and one usage count. Bodies,
|
|
62
66
|
* headers, query values and the raw path are never logged.
|
|
63
67
|
*/
|
|
@@ -98,9 +102,14 @@ class ExecuteEntityProxyRequest {
|
|
|
98
102
|
audit.requestBytes = validated.requestBytes;
|
|
99
103
|
const path = normalizeProxyPath(validated.path);
|
|
100
104
|
|
|
105
|
+
// Visible in the active organization, or 404 (ADR-056 §3.6): the
|
|
106
|
+
// same clauses the v2 lists use, so a missing entity, another
|
|
107
|
+
// person's and one paired with another organization look alike.
|
|
101
108
|
const { entity, definition, module } =
|
|
102
109
|
await this.getEntityModuleForUser.execute(entityId, user);
|
|
103
110
|
audit.module = definition.moduleName;
|
|
111
|
+
this.assertProxyable(entityId, entity, definition, user);
|
|
112
|
+
|
|
104
113
|
const credentialRef = entity.credential;
|
|
105
114
|
const credentialId =
|
|
106
115
|
credentialRef && typeof credentialRef === 'object'
|
|
@@ -202,6 +211,44 @@ class ExecuteEntityProxyRequest {
|
|
|
202
211
|
}
|
|
203
212
|
}
|
|
204
213
|
|
|
214
|
+
/**
|
|
215
|
+
* ADR-056 §3.5: the caller needs `use` access to the entity in the active
|
|
216
|
+
* organization, and the entity's scope must be one the app lets the proxy
|
|
217
|
+
* accept. `access` and `scope` come from the description the v2 lists
|
|
218
|
+
* return (GetEntityModuleForUser.describe), never from a second rule.
|
|
219
|
+
*
|
|
220
|
+
* Both refusals are 403 PROXY_NOT_ALLOWED: the caller can already see the
|
|
221
|
+
* entity, so saying it exists leaks nothing. `details.reason` tells them
|
|
222
|
+
* apart ('access' or 'scope').
|
|
223
|
+
*/
|
|
224
|
+
assertProxyable(entityId, entity, definition, user) {
|
|
225
|
+
const tenancy = this.getEntityModuleForUser.describe(entity, user);
|
|
226
|
+
if (!tenancy) {
|
|
227
|
+
// Not visible after all: the same 404 as a missing entity.
|
|
228
|
+
throw Boom.notFound(`Entity ${entityId} not found`, {
|
|
229
|
+
code: 'ENTITY_NOT_FOUND',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (!grantsUse(tenancy.access)) {
|
|
233
|
+
throw new ProxyRequestError(
|
|
234
|
+
'PROXY_NOT_ALLOWED',
|
|
235
|
+
'Proxying through an entity needs use access to it; you can only view this one',
|
|
236
|
+
{ module: definition.moduleName, reason: 'access' }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
// Narrowest by default (§3.5): with no setting only individual
|
|
240
|
+
// entities are proxied, because proxying through a shared credential
|
|
241
|
+
// lets any member act on the organization's account.
|
|
242
|
+
const acceptedScopes = this.proxyConfig.scopes ?? [SCOPES.INDIVIDUAL];
|
|
243
|
+
if (!acceptedScopes.includes(tenancy.scope)) {
|
|
244
|
+
throw new ProxyRequestError(
|
|
245
|
+
'PROXY_NOT_ALLOWED',
|
|
246
|
+
`The entity proxy does not accept ${tenancy.scope} entities in this app`,
|
|
247
|
+
{ module: definition.moduleName, reason: 'scope', scope: tenancy.scope }
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
205
252
|
/** A module may pin its proxy base URL (string, or a function of the api). */
|
|
206
253
|
resolveBaseUrl(definition, api) {
|
|
207
254
|
const declared = definition.proxy?.baseUrl;
|
|
@@ -3,7 +3,7 @@ const { findDefinition, getAuthStepCount } = require('./entity-types');
|
|
|
3
3
|
|
|
4
4
|
const defaultCreateModule = (params) => {
|
|
5
5
|
const { Module } = require('../module');
|
|
6
|
-
return
|
|
6
|
+
return Module.create(params);
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -80,7 +80,7 @@ class GetAuthorizationRequirements {
|
|
|
80
80
|
if (typeof definition.getAuthRequirementsForStep === 'function') {
|
|
81
81
|
raw = await definition.getAuthRequirementsForStep(step);
|
|
82
82
|
} else {
|
|
83
|
-
const module = this.createModule({
|
|
83
|
+
const module = await this.createModule({
|
|
84
84
|
userId,
|
|
85
85
|
definition,
|
|
86
86
|
...(state ? { state } : {}),
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
2
|
const { mapModuleClassToModuleDTO } = require('../utils/map-module-dto');
|
|
3
|
+
const { describeRecord, visibilityClauses } = require('../scope-visibility');
|
|
4
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
3
5
|
|
|
4
6
|
class GetEntitiesForUser {
|
|
5
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
7
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
8
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
6
9
|
this.moduleRepository = moduleRepository;
|
|
7
10
|
|
|
8
11
|
this.definitionMap = new Map();
|
|
@@ -11,21 +14,31 @@ class GetEntitiesForUser {
|
|
|
11
14
|
}
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The entities a `User` may see in the active organization (ADR-056 §3.6).
|
|
19
|
+
* @param {Object} user - authenticated User
|
|
20
|
+
*/
|
|
21
|
+
async execute(user) {
|
|
22
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
23
|
+
const found = await this.moduleRepository.findEntitiesByOwnership(
|
|
24
|
+
visibilityClauses(ctx, 'entity')
|
|
25
|
+
);
|
|
26
|
+
const entities = (found || []).filter((entity) =>
|
|
27
|
+
describeRecord(entity, ctx, 'entity')
|
|
17
28
|
);
|
|
18
29
|
|
|
19
|
-
return
|
|
20
|
-
|
|
30
|
+
return Promise.all(
|
|
31
|
+
entities.map(async (entity) => {
|
|
32
|
+
const definition = this.definitionMap.get(entity.moduleName);
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
const moduleInstance = await Module.create({
|
|
35
|
+
userId: entity.userId,
|
|
36
|
+
definition: definition,
|
|
37
|
+
entity: entity,
|
|
38
|
+
});
|
|
39
|
+
return mapModuleClassToModuleDTO(moduleInstance);
|
|
40
|
+
})
|
|
41
|
+
);
|
|
29
42
|
}
|
|
30
43
|
}
|
|
31
44
|
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { describeRecord } = require('../scope-visibility');
|
|
3
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
4
|
+
const { isEntityNotFound } = require('../repositories/entity-not-found');
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* True when `user` may act on records owned by `ownerId`. Uses the User
|
|
5
8
|
* domain object's rule (individual or organisation user model) when present.
|
|
9
|
+
* This is the owner-only rule: it ignores the organization a record belongs
|
|
10
|
+
* to, so it no longer decides access to an entity or an integration
|
|
11
|
+
* (modules/scope-visibility.js does). Credentials still use it.
|
|
6
12
|
* @param {Object} user - authenticated User (or a plain { getId } double)
|
|
7
13
|
* @param {string|number} ownerId
|
|
8
14
|
*/
|
|
@@ -14,7 +20,7 @@ function userOwns(user, ownerId) {
|
|
|
14
20
|
|
|
15
21
|
const defaultCreateModule = (params) => {
|
|
16
22
|
const { Module } = require('../module');
|
|
17
|
-
return
|
|
23
|
+
return Module.create(params);
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
/**
|
|
@@ -30,8 +36,11 @@ class GetEntityModuleForUser {
|
|
|
30
36
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
31
37
|
* @param {Array<Object>} params.moduleDefinitions
|
|
32
38
|
* @param {Function} [params.createModule] - builds a Module; injectable for tests.
|
|
39
|
+
* @param {Object} [params.scopePolicy] - from createScopePolicy; with none the
|
|
40
|
+
* app configures nothing (ADR-056 §3.1).
|
|
33
41
|
*/
|
|
34
|
-
constructor({ moduleRepository, moduleDefinitions, createModule }) {
|
|
42
|
+
constructor({ moduleRepository, moduleDefinitions, createModule, scopePolicy }) {
|
|
43
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
35
44
|
this.moduleRepository = moduleRepository;
|
|
36
45
|
this.moduleDefinitions = moduleDefinitions || [];
|
|
37
46
|
this.createModule = createModule || defaultCreateModule;
|
|
@@ -53,7 +62,7 @@ class GetEntityModuleForUser {
|
|
|
53
62
|
{ code: 'ENTITY_TYPE_NOT_FOUND' }
|
|
54
63
|
);
|
|
55
64
|
}
|
|
56
|
-
const module = this.createModule({
|
|
65
|
+
const module = await this.createModule({
|
|
57
66
|
userId: entity.userId,
|
|
58
67
|
entity,
|
|
59
68
|
definition,
|
|
@@ -61,26 +70,44 @@ class GetEntityModuleForUser {
|
|
|
61
70
|
return { entity, definition, module };
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* The entity record, or a 404 when it is missing or the caller may not use
|
|
75
|
+
* it: not theirs, or paired with another organization (ADR-056 §3.6). A
|
|
76
|
+
* `global` entity is never the tenant's to load.
|
|
77
|
+
*/
|
|
65
78
|
async loadOwnedEntity(entityId, user) {
|
|
66
79
|
let entity = null;
|
|
67
80
|
try {
|
|
68
81
|
entity = await this.moduleRepository.findEntityById(entityId);
|
|
69
82
|
} catch (error) {
|
|
70
|
-
//
|
|
71
|
-
|
|
83
|
+
// Every adapter throws for a missing row, and a malformed id (e.g.
|
|
84
|
+
// not an integer on Postgres) is "not found" too. Both must be the
|
|
85
|
+
// 404 another tenant's entity gets, or the status says which ids
|
|
86
|
+
// exist. Anything else is a real failure.
|
|
87
|
+
if (
|
|
88
|
+
isEntityNotFound(error) ||
|
|
89
|
+
/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')
|
|
90
|
+
) {
|
|
72
91
|
entity = null;
|
|
73
92
|
} else {
|
|
74
93
|
throw error;
|
|
75
94
|
}
|
|
76
95
|
}
|
|
77
|
-
if (!entity || !
|
|
96
|
+
if (!entity || !this.describe(entity, user)) {
|
|
78
97
|
throw Boom.notFound(`Entity ${entityId} not found`, {
|
|
79
98
|
code: 'ENTITY_NOT_FOUND',
|
|
80
99
|
});
|
|
81
100
|
}
|
|
82
101
|
return entity;
|
|
83
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `{ scope, owner, organizationId, access }` of an entity to this caller,
|
|
106
|
+
* or null when they may not see it (§3.4).
|
|
107
|
+
*/
|
|
108
|
+
describe(entity, user) {
|
|
109
|
+
return describeRecord(entity, this.scopePolicy.accessContext(user), 'entity');
|
|
110
|
+
}
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
module.exports = { GetEntityModuleForUser, userOwns };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class GetEntityOptionsById {
|
|
4
5
|
/**
|
|
@@ -6,9 +7,11 @@ class GetEntityOptionsById {
|
|
|
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
|
/**
|
|
@@ -35,7 +38,7 @@ class GetEntityOptionsById {
|
|
|
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 GetEntityOptionsById {
|
|
|
57
60
|
);
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
66
|
+
const module = await Module.create({
|
|
67
|
+
userId: entity.userId ?? userId,
|
|
62
68
|
entity,
|
|
63
69
|
definition: moduleDefinition,
|
|
64
70
|
});
|
|
@@ -21,7 +21,7 @@ class GetEntityOptionsByType {
|
|
|
21
21
|
if (!moduleDefinition) {
|
|
22
22
|
throw new Error(`Module definition not found for type: ${type}`);
|
|
23
23
|
}
|
|
24
|
-
const moduleInstance =
|
|
24
|
+
const moduleInstance = await Module.create({
|
|
25
25
|
userId,
|
|
26
26
|
definition: moduleDefinition,
|
|
27
27
|
});
|
|
@@ -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) {
|
|
@@ -53,8 +56,11 @@ class GetModule {
|
|
|
53
56
|
);
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
60
|
+
// organization's entity, and a token refresh during this call must
|
|
61
|
+
// update the owner's credential, not create one under the caller.
|
|
62
|
+
const module = await Module.create({
|
|
63
|
+
userId: entity.userId ?? userId,
|
|
58
64
|
entity,
|
|
59
65
|
definition: moduleDefinition,
|
|
60
66
|
});
|
|
@@ -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,12 +3,14 @@ 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
|
|
|
9
11
|
const defaultCreateModule = (params) => {
|
|
10
12
|
const { Module } = require('../module');
|
|
11
|
-
return
|
|
13
|
+
return Module.create(params);
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
const isMultiStep = (definition) =>
|
|
@@ -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 = await 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', {
|
|
@@ -70,7 +88,7 @@ class ProcessAuthorizationCallback {
|
|
|
70
88
|
});
|
|
71
89
|
}
|
|
72
90
|
|
|
73
|
-
const module =
|
|
91
|
+
const module = await Module.create({
|
|
74
92
|
userId,
|
|
75
93
|
entity: existingEntity,
|
|
76
94
|
definition: moduleDefinition,
|
|
@@ -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
|
|