@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.
Files changed (77) hide show
  1. package/README.md +36 -0
  2. package/application/commands/integration-commands.js +20 -10
  3. package/application/index.js +6 -2
  4. package/database/encryption/encryption-schema-registry.js +7 -0
  5. package/database/organization-where.js +18 -0
  6. package/generated/prisma-mongodb/edge.js +5 -3
  7. package/generated/prisma-mongodb/index-browser.js +2 -0
  8. package/generated/prisma-mongodb/index.d.ts +115 -2
  9. package/generated/prisma-mongodb/index.js +5 -3
  10. package/generated/prisma-mongodb/package.json +1 -1
  11. package/generated/prisma-mongodb/schema.prisma +15 -0
  12. package/generated/prisma-mongodb/wasm.js +5 -3
  13. package/generated/prisma-postgresql/edge.js +5 -3
  14. package/generated/prisma-postgresql/index-browser.js +2 -0
  15. package/generated/prisma-postgresql/index.d.ts +131 -2
  16. package/generated/prisma-postgresql/index.js +5 -3
  17. package/generated/prisma-postgresql/package.json +1 -1
  18. package/generated/prisma-postgresql/schema.prisma +15 -0
  19. package/generated/prisma-postgresql/wasm.js +5 -3
  20. package/handlers/app-definition-loader.js +25 -8
  21. package/handlers/backend-utils.js +31 -25
  22. package/handlers/routers/health.js +1 -4
  23. package/handlers/workers/integration-defined-workers.js +5 -2
  24. package/index.js +10 -2
  25. package/integrations/index.js +0 -4
  26. package/integrations/integration-router.js +99 -52
  27. package/integrations/repositories/integration-not-found.js +14 -0
  28. package/integrations/repositories/integration-repository-documentdb.js +34 -3
  29. package/integrations/repositories/integration-repository-interface.js +17 -1
  30. package/integrations/repositories/integration-repository-mongo.js +59 -76
  31. package/integrations/repositories/integration-repository-postgres.js +62 -76
  32. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  33. package/integrations/use-cases/create-integration.js +55 -20
  34. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  35. package/integrations/use-cases/index.js +5 -1
  36. package/integrations/use-cases/integration-operations-for-user.js +40 -19
  37. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  38. package/integrations/utils/map-integration-dto.js +1 -15
  39. package/management-api/management-api-config.js +3 -0
  40. package/management-api/openapi/components.js +46 -1
  41. package/management-api/openapi/v2-operations.js +14 -1
  42. package/management-api/v2/dependencies.js +56 -14
  43. package/management-api/v2/dto.js +17 -0
  44. package/management-api/v2/handlers/authorization.js +1 -0
  45. package/management-api/v2/handlers/entities.js +10 -7
  46. package/management-api/v2/handlers/integrations.js +6 -3
  47. package/management-api/v2/request.js +32 -1
  48. package/modules/entity-scope.js +15 -0
  49. package/modules/entity-type-registry.js +311 -0
  50. package/modules/index.js +14 -0
  51. package/modules/repositories/module-repository-documentdb.js +41 -1
  52. package/modules/repositories/module-repository-interface.js +19 -2
  53. package/modules/repositories/module-repository-mongo.js +64 -73
  54. package/modules/repositories/module-repository-postgres.js +63 -73
  55. package/modules/scope-config.js +155 -0
  56. package/modules/scope-policy.js +80 -0
  57. package/modules/scope-resolution.js +277 -0
  58. package/modules/scope-visibility.js +165 -0
  59. package/modules/tests/doubles/ownership-matching.js +18 -0
  60. package/modules/use-cases/authorize-entity-for-user.js +59 -0
  61. package/modules/use-cases/entity-operations-for-user.js +42 -10
  62. package/modules/use-cases/entity-types.js +62 -14
  63. package/modules/use-cases/execute-entity-proxy-request.js +15 -0
  64. package/modules/use-cases/get-entities-for-user.js +16 -5
  65. package/modules/use-cases/get-entity-module-for-user.js +23 -3
  66. package/modules/use-cases/get-entity-options-by-id.js +5 -2
  67. package/modules/use-cases/get-module.js +5 -2
  68. package/modules/use-cases/multi-step-authorization.js +36 -12
  69. package/modules/use-cases/process-authorization-callback.js +52 -18
  70. package/modules/use-cases/refresh-entity-options.js +5 -2
  71. package/modules/use-cases/test-module-auth.js +5 -2
  72. package/package.json +5 -5
  73. package/prisma-mongodb/schema.prisma +15 -0
  74. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  75. package/prisma-postgresql/schema.prisma +15 -0
  76. package/types/index.d.ts +1 -0
  77. 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
- /** The caller's connected entities. */
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
- /** @param {Object} user - authenticated User */
16
- async execute(user) {
17
- const entities = await this.moduleRepository.findEntitiesByUserId(
18
- user.getId()
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
- return Array.isArray(entities) ? entities : [];
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 owns. */
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
- return this.getEntityModuleForUser.loadOwnedEntity(entityId, user);
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 owns. Refuses with 409 while integrations
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: definition.moduleName,
30
+ type: typeName,
30
31
  name:
31
32
  callIfFunction(definition, 'getDisplayName') ??
32
33
  display.label ??
33
34
  display.name ??
34
- definition.moduleName,
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
- /** Lists the entity types (API modules) this app can authorize. */
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({ moduleDefinitions }) {
64
- this.moduleDefinitions = moduleDefinitions || [];
102
+ constructor({ entityTypeRegistry, scopePolicy }) {
103
+ this.registry = requireRegistry(entityTypeRegistry);
104
+ this.scopePolicy = scopePolicy;
65
105
  }
66
106
 
67
- async execute() {
68
- return this.moduleDefinitions
69
- .map(describeEntityType)
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; 404 when the app does not configure it. */
115
+ /** Describes one entity type, looked up by its registry key; 404 when absent. */
75
116
  class GetEntityType {
76
- constructor({ moduleDefinitions }) {
77
- this.moduleDefinitions = moduleDefinitions || [];
117
+ constructor({ entityTypeRegistry, scopePolicy }) {
118
+ this.registry = requireRegistry(entityTypeRegistry);
119
+ this.scopePolicy = scopePolicy;
78
120
  }
79
121
 
80
- async execute(entityType) {
81
- return describeEntityType(findDefinition(this.moduleDefinitions, entityType));
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
- async execute(userId) {
15
- const entities = await this.moduleRepository.findEntitiesByUserId(
16
- userId
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
- /** The entity record, or a 404 when it is missing or not the caller's. */
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 || !userOwns(user, entity.userId)) {
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
- ? userIdOrUser.ownsUserId(entity.userId)
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
- ? userIdOrUser.ownsUserId(entity.userId)
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
- processAuthorizationCallback,
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.processAuthorizationCallback = processAuthorizationCallback;
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
- async execute({ user, entityType, data, step = 1, sessionId, credentialId = null }) {
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({ userId, entityType, params: data, credentialId });
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
- userId,
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({ userId, entityType, params, credentialId }) {
282
+ async complete({ ownership, user, entityType, params, credentialId }) {
265
283
  let callback;
266
284
  try {
267
- callback = await this.processAuthorizationCallback.execute(
268
- userId,
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)) {