@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.
Files changed (156) hide show
  1. package/CHANGELOG.md +0 -11
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +2 -2
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/core/Worker.js +31 -7
  8. package/core/create-handler.js +9 -0
  9. package/credential/use-cases/credential-operations-for-user.js +110 -25
  10. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  11. package/database/config.js +48 -24
  12. package/database/documentdb-utils.js +6 -1
  13. package/database/encryption/README.md +0 -2
  14. package/database/encryption/encryption-config.js +10 -31
  15. package/database/encryption/encryption-schema-registry.js +99 -47
  16. package/database/encryption/field-encryption-service.js +5 -69
  17. package/database/organization-where.js +18 -0
  18. package/database/prisma.js +4 -2
  19. package/database/unique-violation.js +25 -0
  20. package/extensions/bootstrap-runner.js +43 -0
  21. package/extensions/extension-loader.js +187 -0
  22. package/extensions/index.js +41 -0
  23. package/extensions/initialize-app.js +123 -0
  24. package/extensions/route-mounter.js +103 -0
  25. package/extensions/schema-composer.js +188 -0
  26. package/generated/prisma-mongodb/edge.js +27 -4
  27. package/generated/prisma-mongodb/index-browser.js +24 -1
  28. package/generated/prisma-mongodb/index.d.ts +3227 -47
  29. package/generated/prisma-mongodb/index.js +27 -4
  30. package/generated/prisma-mongodb/package.json +1 -1
  31. package/generated/prisma-mongodb/schema.prisma +65 -0
  32. package/generated/prisma-mongodb/wasm.js +27 -4
  33. package/generated/prisma-postgresql/edge.js +27 -4
  34. package/generated/prisma-postgresql/index-browser.js +24 -1
  35. package/generated/prisma-postgresql/index.d.ts +3519 -67
  36. package/generated/prisma-postgresql/index.js +27 -4
  37. package/generated/prisma-postgresql/package.json +1 -1
  38. package/generated/prisma-postgresql/schema.prisma +61 -0
  39. package/generated/prisma-postgresql/wasm.js +27 -4
  40. package/handlers/app-definition-loader.js +103 -12
  41. package/handlers/routers/auth.js +40 -12
  42. package/handlers/routers/health.js +4 -2
  43. package/handlers/routers/integration-defined-routers.js +144 -92
  44. package/handlers/routers/integration-webhook-routers.js +102 -64
  45. package/index.js +63 -1
  46. package/infrastructure/scheduler/index.js +19 -6
  47. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  48. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  49. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  50. package/integrations/index.js +5 -0
  51. package/integrations/integration-router.js +115 -73
  52. package/integrations/repositories/integration-not-found.js +14 -0
  53. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  54. package/integrations/repositories/integration-repository-interface.js +17 -1
  55. package/integrations/repositories/integration-repository-mongo.js +59 -76
  56. package/integrations/repositories/integration-repository-postgres.js +62 -76
  57. package/integrations/schedule-config.js +80 -0
  58. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  59. package/integrations/use-cases/create-integration.js +55 -20
  60. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  61. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  62. package/integrations/use-cases/index.js +7 -1
  63. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  64. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  65. package/integrations/utils/map-integration-dto.js +44 -1
  66. package/management-api/management-api-config.js +71 -2
  67. package/management-api/meta-router.js +21 -2
  68. package/management-api/openapi/components.js +40 -0
  69. package/management-api/openapi/v2-operations.js +16 -4
  70. package/management-api/route-registry.js +123 -6
  71. package/management-api/v2/dependencies.js +40 -15
  72. package/management-api/v2/dto.js +20 -0
  73. package/management-api/v2/handlers/authorization.js +1 -0
  74. package/management-api/v2/handlers/credentials.js +6 -2
  75. package/management-api/v2/handlers/entities.js +10 -7
  76. package/management-api/v2/handlers/integrations.js +6 -3
  77. package/management-api/v2/request.js +32 -1
  78. package/modules/entity-scope.js +15 -0
  79. package/modules/index.js +8 -0
  80. package/modules/module-factory.js +12 -0
  81. package/modules/module.js +53 -2
  82. package/modules/repositories/entity-not-found.js +18 -0
  83. package/modules/repositories/module-repository-documentdb.js +51 -6
  84. package/modules/repositories/module-repository-interface.js +19 -2
  85. package/modules/repositories/module-repository-mongo.js +67 -74
  86. package/modules/repositories/module-repository-postgres.js +66 -74
  87. package/modules/repositories/module-repository.js +2 -1
  88. package/modules/scope-config.js +192 -0
  89. package/modules/scope-policy.js +112 -0
  90. package/modules/scope-resolution.js +288 -0
  91. package/modules/scope-visibility.js +247 -0
  92. package/modules/tests/doubles/ownership-matching.js +18 -0
  93. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  94. package/modules/use-cases/create-entity-from-credential.js +113 -0
  95. package/modules/use-cases/entity-operations-for-user.js +50 -13
  96. package/modules/use-cases/entity-types.js +31 -7
  97. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  98. package/modules/use-cases/get-authorization-requirements.js +2 -2
  99. package/modules/use-cases/get-entities-for-user.js +26 -13
  100. package/modules/use-cases/get-entity-module-for-user.js +34 -7
  101. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  102. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  103. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  104. package/modules/use-cases/get-module.js +10 -4
  105. package/modules/use-cases/manage-access.js +21 -0
  106. package/modules/use-cases/multi-step-authorization.js +40 -13
  107. package/modules/use-cases/process-authorization-callback.js +53 -19
  108. package/modules/use-cases/refresh-entity-options.js +10 -4
  109. package/modules/use-cases/test-module-auth.js +10 -4
  110. package/package.json +6 -5
  111. package/prisma-mongodb/schema.prisma +65 -0
  112. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  113. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  114. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  115. package/prisma-postgresql/schema.prisma +61 -0
  116. package/providers/index.js +23 -0
  117. package/providers/resolve-provider.js +93 -0
  118. package/providers/runtime-detection.js +124 -0
  119. package/queues/index.js +25 -0
  120. package/queues/providers/index.js +11 -0
  121. package/queues/providers/netlify-background-provider.js +150 -0
  122. package/queues/providers/qstash-queue-provider.js +219 -0
  123. package/queues/providers/sqs-queue-provider.js +70 -0
  124. package/queues/queue-client-interface.js +55 -0
  125. package/queues/queue-message-auth.js +193 -0
  126. package/queues/queue-provider-factory.js +101 -0
  127. package/queues/queue-provider.js +65 -0
  128. package/queues/sqs-queue-client.js +81 -0
  129. package/types/index.d.ts +1 -0
  130. package/types/modules/index.d.ts +139 -0
  131. package/types/user/index.d.ts +134 -0
  132. package/user/adopter-jwt-config.js +177 -0
  133. package/user/adopter-jwt-errors.js +59 -0
  134. package/user/adopter-token-verifier.js +122 -0
  135. package/user/app-user-helpers.js +97 -0
  136. package/user/create-user-authentication.js +64 -0
  137. package/user/membership.js +51 -0
  138. package/user/repositories/organization-membership-record.js +19 -0
  139. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  140. package/user/repositories/organization-membership-repository-factory.js +42 -0
  141. package/user/repositories/organization-membership-repository-interface.js +57 -0
  142. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  143. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  144. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  145. package/user/repositories/user-repository-documentdb.js +27 -2
  146. package/user/repositories/user-repository-interface.js +18 -0
  147. package/user/repositories/user-repository-mongo.js +24 -0
  148. package/user/repositories/user-repository-postgres.js +18 -0
  149. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  150. package/user/use-cases/authenticate-user.js +7 -1
  151. package/user/use-cases/ensure-organization-membership.js +92 -0
  152. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  153. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  154. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  155. package/user/user.js +64 -1
  156. 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 ownership
59
- * (404), the module's allow-list (403, deny by default), credential
60
- * readiness (401), then the call through Requester._proxyRequest. Every call,
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 new Module(params);
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
- 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
- return entities.map((entity) => {
20
- const definition = this.definitionMap.get(entity.moduleName);
30
+ return Promise.all(
31
+ entities.map(async (entity) => {
32
+ const definition = this.definitionMap.get(entity.moduleName);
21
33
 
22
- const moduleInstance = new Module({
23
- userId,
24
- definition: definition,
25
- entity: entity,
26
- });
27
- return mapModuleClassToModuleDTO(moduleInstance);
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 new Module(params);
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
- /** The entity record, or a 404 when it is missing or not the caller's. */
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
- // A malformed id (e.g. not an integer on Postgres) is "not found".
71
- if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
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 || !userOwns(user, entity.userId)) {
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
- ? userIdOrUser.ownsUserId(entity.userId)
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
- const module = new Module({
61
- userId,
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 = new Module({
24
+ const moduleInstance = await Module.create({
25
25
  userId,
26
26
  definition: moduleDefinition,
27
27
  });
@@ -23,7 +23,7 @@ class GetModuleInstanceFromType {
23
23
  if (!moduleDefinition) {
24
24
  throw new Error(`Module definition not found for type: ${type}`);
25
25
  }
26
- return new Module({
26
+ return Module.create({
27
27
  userId,
28
28
  definition: moduleDefinition,
29
29
  state: options.state,
@@ -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) {
@@ -53,8 +56,11 @@ class GetModule {
53
56
  );
54
57
  }
55
58
 
56
- const module = new Module({
57
- userId,
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 new Module(params);
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
- 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
+ * @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({ userId, entityType, params: data, credentialId });
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
- userId,
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({ userId, entityType, params, credentialId }) {
285
+ async complete({ ownership, user, entityType, params, credentialId }) {
265
286
  let callback;
266
287
  try {
267
- callback = await this.processAuthorizationCallback.execute(
268
- userId,
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
- async execute(userId, entityType, params) {
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 = new 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(entityDetails, moduleName, credentialId) {
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
- const existingEntity = await this.moduleRepository.findEntity({
251
- externalId: identifiers.externalId,
252
- user: userId,
253
- moduleName: moduleName,
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
- return await this.moduleRepository.createEntity({
286
- ...identifiers,
287
- ...details,
288
- moduleName: moduleName,
289
- credential: credentialId,
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