@friggframework/core 2.0.0--canary.681.2df9ef0.0 → 2.0.0--canary.682.cbca9e2.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 +36 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/index.js +6 -2
- 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 +99 -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 +40 -19
- 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 +56 -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 +155 -0
- package/modules/scope-policy.js +80 -0
- package/modules/scope-resolution.js +277 -0
- package/modules/scope-visibility.js +165 -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 +42 -10
- 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/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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorizes an entity for a caller: decides who will own it and in which
|
|
3
|
+
* organization (ADR-056 §3.2, §3.6), then runs the module's authorization.
|
|
4
|
+
* Shared by the v1 `POST /api/authorize` and the v2 authorize flow, so the
|
|
5
|
+
* owner is decided in one place and never by `user.primary`.
|
|
6
|
+
*/
|
|
7
|
+
class AuthorizeEntityForUser {
|
|
8
|
+
/**
|
|
9
|
+
* @param {Object} params
|
|
10
|
+
* @param {Object} params.scopePolicy - from createScopePolicy
|
|
11
|
+
* @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback
|
|
12
|
+
*/
|
|
13
|
+
constructor({ scopePolicy, processAuthorizationCallback }) {
|
|
14
|
+
this.scopePolicy = scopePolicy;
|
|
15
|
+
this.processAuthorizationCallback = processAuthorizationCallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The scope, owner and organization a new entity of `entityType` gets for
|
|
20
|
+
* this caller. Throws a 400/403 when the request cannot have one.
|
|
21
|
+
*
|
|
22
|
+
* @param {{user: Object, entityType: string, scope?: string}} params
|
|
23
|
+
*/
|
|
24
|
+
resolveOwnership({ user, entityType, scope }) {
|
|
25
|
+
return this.scopePolicy.resolveOwnership({
|
|
26
|
+
entityType,
|
|
27
|
+
requestScope: scope,
|
|
28
|
+
user,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {Object} params
|
|
34
|
+
* @param {Object} params.user - authenticated User
|
|
35
|
+
* @param {string} params.entityType
|
|
36
|
+
* @param {Object} params.data - what the module's authorization needs
|
|
37
|
+
* @param {string} [params.scope] - the scope the request asks for
|
|
38
|
+
* @param {Object} [params.ownership] - from `resolveOwnership`, when the
|
|
39
|
+
* caller needed it earlier (a multi-step flow does)
|
|
40
|
+
* @returns {Promise<{result: Object, ownership: Object}>} the callback's
|
|
41
|
+
* `{ credential_id, entity_id, type }` and the ownership it ran with
|
|
42
|
+
*/
|
|
43
|
+
async execute({ user, entityType, data, scope, ownership }) {
|
|
44
|
+
const resolved = ownership ?? this.resolveOwnership({ user, entityType, scope });
|
|
45
|
+
const result = await this.processAuthorizationCallback.execute(
|
|
46
|
+
resolved.ownerId,
|
|
47
|
+
entityType,
|
|
48
|
+
data,
|
|
49
|
+
{
|
|
50
|
+
scope: resolved.scope,
|
|
51
|
+
organizationId: resolved.organizationId,
|
|
52
|
+
legacyOrgId: resolved.legacyOrgId,
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
return { result, ownership: resolved };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { AuthorizeEntityForUser };
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const {
|
|
3
|
+
describeRecord,
|
|
4
|
+
visibilityClauses,
|
|
5
|
+
matchesListFilters,
|
|
6
|
+
} = require('../scope-visibility');
|
|
7
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Management API v2 entity operations. Each one starts from
|
|
@@ -6,29 +12,55 @@ const Boom = require('@hapi/boom');
|
|
|
6
12
|
* (missing or foreign entity = 404).
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The entities the caller may see in the active organization (ADR-056 §3.4,
|
|
17
|
+
* §3.6): the organization's own, and the caller's individual ones by the
|
|
18
|
+
* visibility rule. Each carries `tenancy` ({scope, owner, organizationId,
|
|
19
|
+
* access}); the stored entity is otherwise untouched.
|
|
20
|
+
*/
|
|
10
21
|
class ListEntitiesForUser {
|
|
11
|
-
constructor({ moduleRepository }) {
|
|
22
|
+
constructor({ moduleRepository, scopePolicy }) {
|
|
12
23
|
this.moduleRepository = moduleRepository;
|
|
24
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
13
25
|
}
|
|
14
26
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} user - authenticated User
|
|
29
|
+
* @param {{scope?: string, owner?: string}} [filters] - `?scope=` and `?owner=`
|
|
30
|
+
*/
|
|
31
|
+
async execute(user, filters = {}) {
|
|
32
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
33
|
+
const entities = await this.moduleRepository.findEntitiesByOwnership(
|
|
34
|
+
visibilityClauses(ctx, 'entity')
|
|
19
35
|
);
|
|
20
|
-
|
|
36
|
+
const listed = [];
|
|
37
|
+
for (const entity of Array.isArray(entities) ? entities : []) {
|
|
38
|
+
// The query is the filter; this is the same rule applied again,
|
|
39
|
+
// so a repository that returns too much cannot widen the list.
|
|
40
|
+
const tenancy = describeRecord(entity, ctx, 'entity');
|
|
41
|
+
if (tenancy && matchesListFilters(tenancy, filters)) {
|
|
42
|
+
listed.push({ ...entity, tenancy });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return listed;
|
|
21
46
|
}
|
|
22
47
|
}
|
|
23
48
|
|
|
24
|
-
/** One entity the caller
|
|
49
|
+
/** One entity the caller may use, with its `tenancy`. */
|
|
25
50
|
class GetEntityForUser {
|
|
26
51
|
constructor({ getEntityModuleForUser }) {
|
|
27
52
|
this.getEntityModuleForUser = getEntityModuleForUser;
|
|
28
53
|
}
|
|
29
54
|
|
|
30
55
|
async execute(entityId, user) {
|
|
31
|
-
|
|
56
|
+
const entity = await this.getEntityModuleForUser.loadOwnedEntity(
|
|
57
|
+
entityId,
|
|
58
|
+
user
|
|
59
|
+
);
|
|
60
|
+
return {
|
|
61
|
+
...entity,
|
|
62
|
+
tenancy: this.getEntityModuleForUser.describe(entity, user),
|
|
63
|
+
};
|
|
32
64
|
}
|
|
33
65
|
}
|
|
34
66
|
|
|
@@ -79,7 +111,7 @@ class RefreshEntityOptionsForUser {
|
|
|
79
111
|
}
|
|
80
112
|
|
|
81
113
|
/**
|
|
82
|
-
* Deletes an entity the caller
|
|
114
|
+
* Deletes an entity the caller may use. Refuses with 409 while integrations
|
|
83
115
|
* still use it, so an integration never points at a missing entity. The
|
|
84
116
|
* credential is left alone (others may share it); delete it through
|
|
85
117
|
* /api/v2/credentials.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { ModuleConstants } = require('../ModuleConstants');
|
|
3
|
+
const { usedByOf } = require('../entity-type-registry');
|
|
3
4
|
|
|
4
5
|
const AUTH_TYPE_BY_REQUESTER = {
|
|
5
6
|
[ModuleConstants.authType.oauth2]: 'oauth2',
|
|
@@ -22,16 +23,16 @@ function getAuthStepCount(definition) {
|
|
|
22
23
|
* definition: display metadata, auth type and step count. Never credentials
|
|
23
24
|
* or env values.
|
|
24
25
|
*/
|
|
25
|
-
function describeEntityType(definition) {
|
|
26
|
+
function describeEntityType(definition, typeName = definition.moduleName) {
|
|
26
27
|
const stepCount = getAuthStepCount(definition);
|
|
27
28
|
const display = definition.display || {};
|
|
28
29
|
const description = {
|
|
29
|
-
type:
|
|
30
|
+
type: typeName,
|
|
30
31
|
name:
|
|
31
32
|
callIfFunction(definition, 'getDisplayName') ??
|
|
32
33
|
display.label ??
|
|
33
34
|
display.name ??
|
|
34
|
-
|
|
35
|
+
typeName,
|
|
35
36
|
authType:
|
|
36
37
|
callIfFunction(definition, 'getAuthType') ??
|
|
37
38
|
AUTH_TYPE_BY_REQUESTER[definition.API?.requesterType] ??
|
|
@@ -58,27 +59,74 @@ function findDefinition(moduleDefinitions, entityType) {
|
|
|
58
59
|
return definition;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* With a scope policy the entry also says which scope a new connection gets
|
|
64
|
+
* and which a request may choose (ADR-056 §3.1), so a UI can offer the choice.
|
|
65
|
+
* `global` is never offered: a tenant request cannot create one.
|
|
66
|
+
*/
|
|
67
|
+
const scopeOf = (scopePolicy, name, user) => {
|
|
68
|
+
if (!scopePolicy) return {};
|
|
69
|
+
const { scope, source, allow } = scopePolicy.resolveScope({
|
|
70
|
+
entityType: name,
|
|
71
|
+
user,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
scope: {
|
|
75
|
+
default: scope,
|
|
76
|
+
allow: allow.filter((value) => value !== 'global'),
|
|
77
|
+
source,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const describeEntry = (name, entry, scopePolicy, user) => ({
|
|
83
|
+
// The registry key is the name the API uses, whatever the definition spells.
|
|
84
|
+
...describeEntityType(entry.definition, name),
|
|
85
|
+
usedBy: usedByOf(entry),
|
|
86
|
+
...scopeOf(scopePolicy, name, user),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function requireRegistry(entityTypeRegistry) {
|
|
90
|
+
if (!(entityTypeRegistry instanceof Map)) {
|
|
91
|
+
throw new TypeError('entityTypeRegistry is required (see getEntityTypeRegistry)');
|
|
92
|
+
}
|
|
93
|
+
return entityTypeRegistry;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Lists the entity types this app can authorize: registry entries plus every
|
|
98
|
+
* module an integration binds (ADR-059). `usedBy` names the integrations that
|
|
99
|
+
* bind each type; it is empty for a registry-only type.
|
|
100
|
+
*/
|
|
62
101
|
class ListEntityTypes {
|
|
63
|
-
constructor({
|
|
64
|
-
this.
|
|
102
|
+
constructor({ entityTypeRegistry, scopePolicy }) {
|
|
103
|
+
this.registry = requireRegistry(entityTypeRegistry);
|
|
104
|
+
this.scopePolicy = scopePolicy;
|
|
65
105
|
}
|
|
66
106
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
/** @param {{user?: Object}} [params] - the caller, for the level-6 scope default */
|
|
108
|
+
async execute({ user } = {}) {
|
|
109
|
+
return [...this.registry]
|
|
110
|
+
.map(([name, entry]) => describeEntry(name, entry, this.scopePolicy, user))
|
|
70
111
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
71
112
|
}
|
|
72
113
|
}
|
|
73
114
|
|
|
74
|
-
/** Describes one entity type
|
|
115
|
+
/** Describes one entity type, looked up by its registry key; 404 when absent. */
|
|
75
116
|
class GetEntityType {
|
|
76
|
-
constructor({
|
|
77
|
-
this.
|
|
117
|
+
constructor({ entityTypeRegistry, scopePolicy }) {
|
|
118
|
+
this.registry = requireRegistry(entityTypeRegistry);
|
|
119
|
+
this.scopePolicy = scopePolicy;
|
|
78
120
|
}
|
|
79
121
|
|
|
80
|
-
async execute(entityType) {
|
|
81
|
-
|
|
122
|
+
async execute(entityType, { user } = {}) {
|
|
123
|
+
const entry = this.registry.get(entityType);
|
|
124
|
+
if (!entry) {
|
|
125
|
+
throw Boom.notFound(`Entity type '${entityType}' not found`, {
|
|
126
|
+
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return describeEntry(entityType, entry, this.scopePolicy, user);
|
|
82
130
|
}
|
|
83
131
|
}
|
|
84
132
|
|
|
@@ -98,9 +98,24 @@ class ExecuteEntityProxyRequest {
|
|
|
98
98
|
audit.requestBytes = validated.requestBytes;
|
|
99
99
|
const path = normalizeProxyPath(validated.path);
|
|
100
100
|
|
|
101
|
+
// Access is `use` in the active organization (ADR-056 §3.5, §3.6): the
|
|
102
|
+
// owner's entity there, or the organization's. A `global` entity is
|
|
103
|
+
// never the tenant's.
|
|
101
104
|
const { entity, definition, module } =
|
|
102
105
|
await this.getEntityModuleForUser.execute(entityId, user);
|
|
103
106
|
audit.module = definition.moduleName;
|
|
107
|
+
// Narrowest by default (§3.5): proxying through a shared credential
|
|
108
|
+
// lets any member act on the organization's account, so only an
|
|
109
|
+
// individual entity may be proxied until `managementApi.proxy.scopes`
|
|
110
|
+
// lets an app opt in to `organization`.
|
|
111
|
+
// TODO(F6): read proxy.scopes here instead of refusing outright.
|
|
112
|
+
if (this.getEntityModuleForUser.describe(entity, user)?.scope !== 'individual') {
|
|
113
|
+
throw new ProxyRequestError(
|
|
114
|
+
'PROXY_NOT_ALLOWED',
|
|
115
|
+
'The entity proxy only accepts individual entities; organization-level entities are not proxied',
|
|
116
|
+
{ module: definition.moduleName, reason: 'scope' }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
104
119
|
const credentialRef = entity.credential;
|
|
105
120
|
const credentialId =
|
|
106
121
|
credentialRef && typeof credentialRef === 'object'
|
|
@@ -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,16 +14,24 @@ 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
30
|
return entities.map((entity) => {
|
|
20
31
|
const definition = this.definitionMap.get(entity.moduleName);
|
|
21
32
|
|
|
22
33
|
const moduleInstance = new Module({
|
|
23
|
-
userId,
|
|
34
|
+
userId: entity.userId,
|
|
24
35
|
definition: definition,
|
|
25
36
|
entity: entity,
|
|
26
37
|
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { describeRecord } = require('../scope-visibility');
|
|
3
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* True when `user` may act on records owned by `ownerId`. Uses the User
|
|
5
7
|
* domain object's rule (individual or organisation user model) when present.
|
|
8
|
+
* This is the owner-only rule: it ignores the organization a record belongs
|
|
9
|
+
* to, so it no longer decides access to an entity or an integration
|
|
10
|
+
* (modules/scope-visibility.js does). Credentials still use it.
|
|
6
11
|
* @param {Object} user - authenticated User (or a plain { getId } double)
|
|
7
12
|
* @param {string|number} ownerId
|
|
8
13
|
*/
|
|
@@ -30,8 +35,11 @@ class GetEntityModuleForUser {
|
|
|
30
35
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
31
36
|
* @param {Array<Object>} params.moduleDefinitions
|
|
32
37
|
* @param {Function} [params.createModule] - builds a Module; injectable for tests.
|
|
38
|
+
* @param {Object} [params.scopePolicy] - from createScopePolicy; with none the
|
|
39
|
+
* app configures nothing (ADR-056 §3.1).
|
|
33
40
|
*/
|
|
34
|
-
constructor({ moduleRepository, moduleDefinitions, createModule }) {
|
|
41
|
+
constructor({ moduleRepository, moduleDefinitions, createModule, scopePolicy }) {
|
|
42
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
35
43
|
this.moduleRepository = moduleRepository;
|
|
36
44
|
this.moduleDefinitions = moduleDefinitions || [];
|
|
37
45
|
this.createModule = createModule || defaultCreateModule;
|
|
@@ -61,7 +69,11 @@ class GetEntityModuleForUser {
|
|
|
61
69
|
return { entity, definition, module };
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* The entity record, or a 404 when it is missing or the caller may not use
|
|
74
|
+
* it: not theirs, or paired with another organization (ADR-056 §3.6). A
|
|
75
|
+
* `global` entity is never the tenant's to load.
|
|
76
|
+
*/
|
|
65
77
|
async loadOwnedEntity(entityId, user) {
|
|
66
78
|
let entity = null;
|
|
67
79
|
try {
|
|
@@ -74,13 +86,21 @@ class GetEntityModuleForUser {
|
|
|
74
86
|
throw error;
|
|
75
87
|
}
|
|
76
88
|
}
|
|
77
|
-
if (!entity || !
|
|
89
|
+
if (!entity || !this.describe(entity, user)) {
|
|
78
90
|
throw Boom.notFound(`Entity ${entityId} not found`, {
|
|
79
91
|
code: 'ENTITY_NOT_FOUND',
|
|
80
92
|
});
|
|
81
93
|
}
|
|
82
94
|
return entity;
|
|
83
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `{ scope, owner, organizationId, access }` of an entity to this caller,
|
|
99
|
+
* or null when they may not see it (§3.4).
|
|
100
|
+
*/
|
|
101
|
+
describe(entity, user) {
|
|
102
|
+
return describeRecord(entity, this.scopePolicy.accessContext(user), 'entity');
|
|
103
|
+
}
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
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) {
|
|
@@ -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) {
|
|
@@ -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)) {
|