@friggframework/core 2.0.0--canary.704.79d9a63.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 (207) hide show
  1. package/CLAUDE.md +28 -40
  2. package/README.md +105 -0
  3. package/application/commands/entity-commands.js +60 -18
  4. package/application/commands/integration-commands.js +20 -73
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/application/index.js +6 -2
  8. package/core/Worker.js +31 -7
  9. package/core/create-handler.js +9 -0
  10. package/credential/repositories/credential-repository-documentdb.js +34 -0
  11. package/credential/repositories/credential-repository-interface.js +15 -0
  12. package/credential/repositories/credential-repository-mongo.js +23 -0
  13. package/credential/repositories/credential-repository-postgres.js +28 -0
  14. package/credential/use-cases/credential-operations-for-user.js +202 -0
  15. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  16. package/database/config.js +48 -24
  17. package/database/documentdb-encryption-service.js +9 -22
  18. package/database/documentdb-utils.js +2 -0
  19. package/database/encryption/README.md +76 -14
  20. package/database/encryption/documentdb-encryption-service.md +78 -1
  21. package/database/encryption/encryption-config.js +219 -0
  22. package/database/encryption/encryption-schema-registry.js +93 -12
  23. package/database/encryption/integration-mapping-encryption.js +1 -1
  24. package/database/organization-where.js +18 -0
  25. package/database/prisma.js +15 -38
  26. package/database/unique-violation.js +25 -0
  27. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  28. package/errors/index.js +0 -4
  29. package/extensions/bootstrap-runner.js +43 -0
  30. package/extensions/extension-loader.js +187 -0
  31. package/extensions/index.js +41 -0
  32. package/extensions/initialize-app.js +123 -0
  33. package/extensions/route-mounter.js +103 -0
  34. package/extensions/schema-composer.js +188 -0
  35. package/generated/prisma-mongodb/edge.js +43 -4
  36. package/generated/prisma-mongodb/index-browser.js +40 -1
  37. package/generated/prisma-mongodb/index.d.ts +4761 -133
  38. package/generated/prisma-mongodb/index.js +43 -4
  39. package/generated/prisma-mongodb/package.json +1 -1
  40. package/generated/prisma-mongodb/schema.prisma +92 -0
  41. package/generated/prisma-mongodb/wasm.js +43 -4
  42. package/generated/prisma-postgresql/edge.js +43 -4
  43. package/generated/prisma-postgresql/index-browser.js +40 -1
  44. package/generated/prisma-postgresql/index.d.ts +4961 -28
  45. package/generated/prisma-postgresql/index.js +43 -4
  46. package/generated/prisma-postgresql/package.json +1 -1
  47. package/generated/prisma-postgresql/schema.prisma +87 -0
  48. package/generated/prisma-postgresql/wasm.js +43 -4
  49. package/handlers/WEBHOOKS.md +0 -29
  50. package/handlers/app-definition-loader.js +121 -19
  51. package/handlers/app-handler-helpers.js +25 -3
  52. package/handlers/backend-utils.js +31 -25
  53. package/handlers/middleware/admin-auth.js +14 -1
  54. package/handlers/routers/auth.js +46 -10
  55. package/handlers/routers/health.js +13 -4
  56. package/handlers/routers/integration-defined-routers.js +144 -92
  57. package/handlers/routers/integration-webhook-routers.js +102 -64
  58. package/handlers/workers/integration-defined-workers.js +5 -2
  59. package/index.js +65 -3
  60. package/infrastructure/scheduler/index.js +19 -6
  61. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  62. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  63. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  64. package/integrations/index.js +1 -0
  65. package/integrations/integration-base.js +0 -29
  66. package/integrations/integration-router.js +131 -96
  67. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  68. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  69. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  70. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  71. package/integrations/repositories/integration-mapping-repository.js +0 -29
  72. package/integrations/repositories/integration-not-found.js +14 -0
  73. package/integrations/repositories/integration-repository-documentdb.js +35 -35
  74. package/integrations/repositories/integration-repository-interface.js +17 -31
  75. package/integrations/repositories/integration-repository-mongo.js +60 -129
  76. package/integrations/repositories/integration-repository-postgres.js +62 -134
  77. package/integrations/repositories/process-repository-mongo.js +1 -1
  78. package/integrations/schedule-config.js +80 -0
  79. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  80. package/integrations/use-cases/create-integration.js +72 -75
  81. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  82. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  83. package/integrations/use-cases/index.js +7 -1
  84. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  85. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  86. package/integrations/utils/map-integration-dto.js +43 -14
  87. package/management-api/create-management-api-router.js +49 -0
  88. package/management-api/error-response.js +116 -0
  89. package/management-api/management-api-config.js +151 -0
  90. package/management-api/meta-router.js +76 -0
  91. package/management-api/openapi/build-openapi.js +144 -0
  92. package/management-api/openapi/components.js +408 -0
  93. package/management-api/openapi/v1-operations.js +83 -0
  94. package/management-api/openapi/v2-operations.js +264 -0
  95. package/management-api/route-registry.js +411 -0
  96. package/management-api/test-support/http-client.js +39 -0
  97. package/management-api/use-cases/get-management-api-meta.js +57 -0
  98. package/management-api/v1-deprecation.js +120 -0
  99. package/management-api/v2/create-v2-router.js +85 -0
  100. package/management-api/v2/dependencies.js +281 -0
  101. package/management-api/v2/dto.js +143 -0
  102. package/management-api/v2/handlers/authorization.js +94 -0
  103. package/management-api/v2/handlers/credentials.js +33 -0
  104. package/management-api/v2/handlers/entities.js +77 -0
  105. package/management-api/v2/handlers/integrations.js +128 -0
  106. package/management-api/v2/handlers/proxy.js +34 -0
  107. package/management-api/v2/request.js +85 -0
  108. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  109. package/modules/domain/authorization-session.js +144 -0
  110. package/modules/entity-scope.js +15 -0
  111. package/modules/entity-type-registry.js +311 -0
  112. package/modules/index.js +14 -0
  113. package/modules/module-factory.js +12 -0
  114. package/modules/module.js +53 -2
  115. package/modules/proxy/proxy-errors.js +20 -0
  116. package/modules/proxy/proxy-path.js +179 -0
  117. package/modules/proxy/proxy-policy.js +248 -0
  118. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  119. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  120. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  121. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  122. package/modules/repositories/entity-not-found.js +18 -0
  123. package/modules/repositories/module-repository-documentdb.js +51 -6
  124. package/modules/repositories/module-repository-interface.js +19 -2
  125. package/modules/repositories/module-repository-mongo.js +67 -74
  126. package/modules/repositories/module-repository-postgres.js +66 -74
  127. package/modules/repositories/module-repository.js +2 -1
  128. package/modules/requester/proxy-request.js +244 -0
  129. package/modules/requester/requester.js +27 -2
  130. package/modules/scope-config.js +192 -0
  131. package/modules/scope-policy.js +112 -0
  132. package/modules/scope-resolution.js +288 -0
  133. package/modules/scope-visibility.js +247 -0
  134. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  135. package/modules/tests/doubles/ownership-matching.js +18 -0
  136. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  137. package/modules/use-cases/create-entity-from-credential.js +113 -0
  138. package/modules/use-cases/entity-operations-for-user.js +162 -0
  139. package/modules/use-cases/entity-types.js +139 -0
  140. package/modules/use-cases/execute-entity-proxy-request.js +286 -0
  141. package/modules/use-cases/get-authorization-requirements.js +100 -0
  142. package/modules/use-cases/get-entities-for-user.js +26 -13
  143. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  144. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  145. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  146. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  147. package/modules/use-cases/get-module.js +10 -4
  148. package/modules/use-cases/manage-access.js +21 -0
  149. package/modules/use-cases/multi-step-authorization.js +329 -0
  150. package/modules/use-cases/process-authorization-callback.js +53 -19
  151. package/modules/use-cases/refresh-entity-options.js +10 -4
  152. package/modules/use-cases/test-module-auth.js +10 -4
  153. package/modules/utils/map-module-dto.js +3 -5
  154. package/package.json +6 -5
  155. package/prisma-mongodb/schema.prisma +92 -0
  156. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  157. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  158. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  159. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  160. package/prisma-postgresql/schema.prisma +87 -0
  161. package/providers/index.js +23 -0
  162. package/providers/resolve-provider.js +93 -0
  163. package/providers/runtime-detection.js +124 -0
  164. package/queues/index.js +25 -0
  165. package/queues/providers/index.js +11 -0
  166. package/queues/providers/netlify-background-provider.js +150 -0
  167. package/queues/providers/qstash-queue-provider.js +219 -0
  168. package/queues/providers/sqs-queue-provider.js +70 -0
  169. package/queues/queue-client-interface.js +55 -0
  170. package/queues/queue-message-auth.js +193 -0
  171. package/queues/queue-provider-factory.js +101 -0
  172. package/queues/queue-provider.js +65 -0
  173. package/queues/sqs-queue-client.js +81 -0
  174. package/types/index.d.ts +2 -0
  175. package/types/modules/index.d.ts +197 -0
  176. package/types/user/index.d.ts +134 -0
  177. package/user/adopter-jwt-config.js +177 -0
  178. package/user/adopter-jwt-errors.js +59 -0
  179. package/user/adopter-token-verifier.js +122 -0
  180. package/user/app-user-helpers.js +97 -0
  181. package/user/create-user-authentication.js +64 -0
  182. package/user/membership.js +51 -0
  183. package/user/repositories/organization-membership-record.js +19 -0
  184. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  185. package/user/repositories/organization-membership-repository-factory.js +42 -0
  186. package/user/repositories/organization-membership-repository-interface.js +57 -0
  187. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  188. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  189. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  190. package/user/repositories/user-repository-documentdb.js +27 -2
  191. package/user/repositories/user-repository-interface.js +18 -0
  192. package/user/repositories/user-repository-mongo.js +24 -0
  193. package/user/repositories/user-repository-postgres.js +18 -0
  194. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  195. package/user/tests/doubles/test-user-repository.js +1 -1
  196. package/user/use-cases/authenticate-user.js +7 -1
  197. package/user/use-cases/ensure-organization-membership.js +92 -0
  198. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  199. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  200. package/user/use-cases/get-user-from-bearer-token.js +2 -7
  201. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  202. package/user/user.js +64 -1
  203. package/errors/mapping-already-exists-error.js +0 -14
  204. package/integrations/use-cases/add-entities-to-integration.js +0 -92
  205. package/integrations/use-cases/remove-entities-from-integration.js +0 -70
  206. package/integrations/utils/assert-entity-ids-input.js +0 -42
  207. package/modules/utils/to-credential-id.js +0 -14
@@ -0,0 +1,113 @@
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');
5
+
6
+ /**
7
+ * True when `user` may act on records owned by `ownerId`. Uses the User
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.
12
+ * @param {Object} user - authenticated User (or a plain { getId } double)
13
+ * @param {string|number} ownerId
14
+ */
15
+ function userOwns(user, ownerId) {
16
+ if (ownerId === undefined || ownerId === null || !user) return false;
17
+ if (typeof user.ownsUserId === 'function') return user.ownsUserId(ownerId);
18
+ return String(user.getId()) === String(ownerId);
19
+ }
20
+
21
+ const defaultCreateModule = (params) => {
22
+ const { Module } = require('../module');
23
+ return Module.create(params);
24
+ };
25
+
26
+ /**
27
+ * Loads an entity the caller owns and builds its Module (API client
28
+ * hydrated from the persisted entity and credential). Management API v2.
29
+ *
30
+ * A missing entity and one owned by someone else are the same 404, so ids
31
+ * cannot be probed (ADR-052 §3).
32
+ */
33
+ class GetEntityModuleForUser {
34
+ /**
35
+ * @param {Object} params
36
+ * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
37
+ * @param {Array<Object>} params.moduleDefinitions
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).
41
+ */
42
+ constructor({ moduleRepository, moduleDefinitions, createModule, scopePolicy }) {
43
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
44
+ this.moduleRepository = moduleRepository;
45
+ this.moduleDefinitions = moduleDefinitions || [];
46
+ this.createModule = createModule || defaultCreateModule;
47
+ }
48
+
49
+ /**
50
+ * @param {string} entityId
51
+ * @param {Object} user - authenticated User
52
+ * @returns {Promise<{ entity: Object, definition: Object, module: Object }>}
53
+ */
54
+ async execute(entityId, user) {
55
+ const entity = await this.loadOwnedEntity(entityId, user);
56
+ const definition = this.moduleDefinitions.find(
57
+ (def) => def.moduleName === entity.moduleName
58
+ );
59
+ if (!definition) {
60
+ throw Boom.notFound(
61
+ `Entity type ${entity.moduleName} is not configured in this app`,
62
+ { code: 'ENTITY_TYPE_NOT_FOUND' }
63
+ );
64
+ }
65
+ const module = await this.createModule({
66
+ userId: entity.userId,
67
+ entity,
68
+ definition,
69
+ });
70
+ return { entity, definition, module };
71
+ }
72
+
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
+ */
78
+ async loadOwnedEntity(entityId, user) {
79
+ let entity = null;
80
+ try {
81
+ entity = await this.moduleRepository.findEntityById(entityId);
82
+ } catch (error) {
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
+ ) {
91
+ entity = null;
92
+ } else {
93
+ throw error;
94
+ }
95
+ }
96
+ if (!entity || !this.describe(entity, user)) {
97
+ throw Boom.notFound(`Entity ${entityId} not found`, {
98
+ code: 'ENTITY_NOT_FOUND',
99
+ });
100
+ }
101
+ return entity;
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
+ }
111
+ }
112
+
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 };
@@ -0,0 +1,329 @@
1
+ const Boom = require('@hapi/boom');
2
+ const { FetchError } = require('../../errors/fetch-error');
3
+ const { getLogger } = require('../../logs');
4
+ const { AuthorizationSession } = require('../domain/authorization-session');
5
+ const { findDefinition, getAuthStepCount } = require('./entity-types');
6
+ const { describeRecord } = require('../scope-visibility');
7
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
8
+
9
+ const log = getLogger('frigg.api.authorize');
10
+
11
+ const defaultCreateModule = (params) => {
12
+ const { Module } = require('../module');
13
+ return Module.create(params);
14
+ };
15
+
16
+ const isMultiStep = (definition) =>
17
+ getAuthStepCount(definition) > 1 ||
18
+ typeof definition.processAuthorizationStep === 'function';
19
+
20
+ const sessionNotFound = () =>
21
+ Boom.notFound('Authorization session not found or expired', {
22
+ code: 'AUTHORIZATION_SESSION_NOT_FOUND',
23
+ });
24
+
25
+ /**
26
+ * Maps a failure from the module or the provider to a client-facing error.
27
+ * Boom errors pass through. A provider HTTP failure is a 502; a failed auth
28
+ * test or a ClientSafeError is a 400. Anything else stays a 500.
29
+ */
30
+ function toAuthorizationError(error, { passMessage = false } = {}) {
31
+ if (error?.isBoom) return error;
32
+ if (error instanceof FetchError) {
33
+ return Boom.badGateway('The provider rejected the authorization request', {
34
+ code: 'UPSTREAM_ERROR',
35
+ expose: true,
36
+ details: { upstreamStatus: error.statusCode ?? null },
37
+ });
38
+ }
39
+ if (error?.isClientSafe || passMessage) {
40
+ return Boom.badRequest(error?.message || 'Authorization failed', {
41
+ code: 'AUTHORIZATION_FAILED',
42
+ });
43
+ }
44
+ if (error?.message === 'Authorization failed') {
45
+ return Boom.badRequest(
46
+ 'Authorization failed: the credentials did not pass the module auth test',
47
+ { code: 'AUTHORIZATION_FAILED' }
48
+ );
49
+ }
50
+ return error;
51
+ }
52
+
53
+ /**
54
+ * Loads the session a caller continues, checking it belongs to them, to the
55
+ * entity type and (for re-authorization) to the credential.
56
+ */
57
+ async function loadSession(repository, sessionId, binding) {
58
+ if (typeof sessionId !== 'string' || !sessionId) throw sessionNotFound();
59
+ const session = await repository.findBySessionId(sessionId);
60
+ if (!session || session.isExpired() || !session.belongsTo(binding)) {
61
+ throw sessionNotFound();
62
+ }
63
+ if (session.completed) {
64
+ throw Boom.conflict('Authorization session is already complete', {
65
+ code: 'AUTHORIZATION_SESSION_COMPLETED',
66
+ });
67
+ }
68
+ return session;
69
+ }
70
+
71
+ async function startSession(repository, binding, maxSteps) {
72
+ // Expired sessions are removed opportunistically; PostgreSQL has no TTL.
73
+ Promise.resolve()
74
+ .then(() => repository.deleteExpired())
75
+ .catch((error) =>
76
+ log.warn('Expired authorization sessions were not removed', {
77
+ eventName: `${log.name}.session_cleanup_failed`,
78
+ error,
79
+ })
80
+ );
81
+ return repository.create(AuthorizationSession.start({ ...binding, maxSteps }));
82
+ }
83
+
84
+ /**
85
+ * GET /api/v2/authorize (and GET /api/v2/credentials/:id/reauthorize): what
86
+ * the caller must submit next. A multi-step flow gets a session on step 1;
87
+ * later steps need that sessionId and must be the session's current step.
88
+ */
89
+ class GetAuthorizationStep {
90
+ /**
91
+ * @param {Object} params
92
+ * @param {Array<Object>} params.moduleDefinitions
93
+ * @param {import('./get-authorization-requirements').GetAuthorizationRequirements} params.getAuthorizationRequirements
94
+ * @param {import('../repositories/authorization-session-repository-interface').AuthorizationSessionRepositoryInterface} params.authorizationSessionRepository
95
+ */
96
+ constructor({ moduleDefinitions, getAuthorizationRequirements, authorizationSessionRepository }) {
97
+ this.moduleDefinitions = moduleDefinitions || [];
98
+ this.getAuthorizationRequirements = getAuthorizationRequirements;
99
+ this.sessions = authorizationSessionRepository;
100
+ }
101
+
102
+ async execute({ user, entityType, step = 1, sessionId, credentialId = null, state }) {
103
+ const definition = findDefinition(this.moduleDefinitions, entityType);
104
+ const userId = user.getId();
105
+ const requirementsFor = (n) =>
106
+ this.getAuthorizationRequirements.execute({ entityType, step: n, userId, state });
107
+
108
+ if (!isMultiStep(definition)) return requirementsFor(step);
109
+
110
+ const binding = { userId, entityType, credentialId };
111
+ let session;
112
+ if (sessionId) {
113
+ session = await loadSession(this.sessions, sessionId, binding);
114
+ if (step !== session.currentStep) {
115
+ throw Boom.conflict(
116
+ `This session expects step ${session.currentStep}`,
117
+ { code: 'STEP_OUT_OF_ORDER', details: { expectedStep: session.currentStep } }
118
+ );
119
+ }
120
+ } else {
121
+ if (step !== 1) {
122
+ throw Boom.badRequest('sessionId is required after step 1', {
123
+ code: 'SESSION_REQUIRED',
124
+ });
125
+ }
126
+ session = await startSession(this.sessions, binding, getAuthStepCount(definition));
127
+ }
128
+ return { ...(await requirementsFor(step)), sessionId: session.sessionId };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * POST /api/v2/authorize (and POST /api/v2/credentials/:id/reauthorize).
134
+ *
135
+ * Single-step modules complete at once through ProcessAuthorizationCallback
136
+ * (the v1 authorize logic). Multi-step modules run
137
+ * definition.processAuthorizationStep(api, step, data, stepData) per step;
138
+ * when it reports `completed`, its `authData` goes through the same
139
+ * callback. Returns either
140
+ * `{ status: 'complete', entity, credential }` (domain records; the handler
141
+ * masks them) or
142
+ * `{ status: 'pending', step, totalSteps, sessionId, requirements, message? }`.
143
+ */
144
+ class SubmitAuthorizationStep {
145
+ constructor({
146
+ moduleDefinitions,
147
+ getAuthorizationRequirements,
148
+ authorizationSessionRepository,
149
+ authorizeEntityForUser,
150
+ moduleRepository,
151
+ credentialRepository,
152
+ createModule,
153
+ scopePolicy,
154
+ }) {
155
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
156
+ this.moduleDefinitions = moduleDefinitions || [];
157
+ this.getAuthorizationRequirements = getAuthorizationRequirements;
158
+ this.sessions = authorizationSessionRepository;
159
+ this.authorizeEntityForUser = authorizeEntityForUser;
160
+ this.moduleRepository = moduleRepository;
161
+ this.credentialRepository = credentialRepository;
162
+ this.createModule = createModule || defaultCreateModule;
163
+ }
164
+
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 }) {
173
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
174
+ throw Boom.badRequest('data must be an object', {
175
+ code: 'VALIDATION_ERROR',
176
+ details: { field: 'data' },
177
+ });
178
+ }
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`.
183
+ const userId = user.getId();
184
+ const ownership = this.authorizeEntityForUser.resolveOwnership({
185
+ user,
186
+ entityType,
187
+ scope,
188
+ fixedScope,
189
+ });
190
+
191
+ if (!isMultiStep(definition)) {
192
+ if (step !== 1) {
193
+ throw Boom.badRequest(`${entityType} has a single authorization step`, {
194
+ code: 'INVALID_STEP',
195
+ });
196
+ }
197
+ return this.complete({ ownership, user, entityType, params: data, credentialId });
198
+ }
199
+
200
+ const totalSteps = getAuthStepCount(definition);
201
+ const binding = { userId, entityType, credentialId };
202
+ let session;
203
+ if (sessionId) {
204
+ session = await loadSession(this.sessions, sessionId, binding);
205
+ if (step === 1 && session.currentStep !== 1) {
206
+ session.restart();
207
+ } else if (step !== session.currentStep) {
208
+ throw Boom.conflict(`This session expects step ${session.currentStep}`, {
209
+ code: 'STEP_OUT_OF_ORDER',
210
+ details: { expectedStep: session.currentStep },
211
+ });
212
+ }
213
+ } else {
214
+ if (step !== 1) {
215
+ throw Boom.badRequest('sessionId is required after step 1', {
216
+ code: 'SESSION_REQUIRED',
217
+ });
218
+ }
219
+ session = await startSession(this.sessions, binding, totalSteps);
220
+ }
221
+
222
+ if (typeof definition.processAuthorizationStep !== 'function') {
223
+ throw Boom.badImplementation(
224
+ `Module ${entityType} declares ${totalSteps} steps but no processAuthorizationStep`
225
+ );
226
+ }
227
+
228
+ const module = await this.createModule({ userId: ownership.ownerId, definition });
229
+ let result;
230
+ try {
231
+ result = await definition.processAuthorizationStep(
232
+ module.api,
233
+ step,
234
+ data,
235
+ session.stepData
236
+ );
237
+ } catch (error) {
238
+ // processAuthorizationStep errors are written for the end user
239
+ // (docs/MULTI_STEP_AUTH_MIGRATION_GUIDE.md), e.g. "Invalid code".
240
+ throw toAuthorizationError(error, { passMessage: true });
241
+ }
242
+
243
+ if (result?.completed) {
244
+ session.markComplete();
245
+ await this.sessions.update(session);
246
+ const outcome = await this.complete({
247
+ ownership,
248
+ user,
249
+ entityType,
250
+ params: result.authData || {},
251
+ credentialId,
252
+ });
253
+ // The collected data is no longer needed once the credential exists.
254
+ await Promise.resolve()
255
+ .then(() => this.sessions.deleteBySessionId(session.sessionId))
256
+ .catch(() => {});
257
+ return outcome;
258
+ }
259
+
260
+ const nextStep = result?.nextStep ?? step + 1;
261
+ try {
262
+ session.advanceTo(nextStep, result?.stepData);
263
+ } catch (error) {
264
+ throw Boom.badImplementation(
265
+ `Module ${entityType} returned an invalid next step: ${error.message}`
266
+ );
267
+ }
268
+ await this.sessions.update(session);
269
+
270
+ const requirements = await this.getAuthorizationRequirements.execute({
271
+ entityType,
272
+ step: nextStep,
273
+ userId,
274
+ });
275
+ return {
276
+ status: 'pending',
277
+ step: nextStep,
278
+ totalSteps,
279
+ sessionId: session.sessionId,
280
+ requirements,
281
+ ...(result?.message && { message: String(result.message) }),
282
+ };
283
+ }
284
+
285
+ async complete({ ownership, user, entityType, params, credentialId }) {
286
+ let callback;
287
+ try {
288
+ ({ result: callback } = await this.authorizeEntityForUser.execute({
289
+ user,
290
+ entityType,
291
+ data: params,
292
+ ownership,
293
+ }));
294
+ } catch (error) {
295
+ throw toAuthorizationError(error);
296
+ }
297
+ const [entity, credential] = await Promise.all([
298
+ this.moduleRepository.findEntityById(callback.entity_id),
299
+ this.credentialRepository.findCredentialById(callback.credential_id),
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;
305
+ const outcome = {
306
+ status: 'complete',
307
+ entity: entity && tenancy ? { ...entity, tenancy } : entity,
308
+ credential: credential && {
309
+ ...credential,
310
+ type: entityType,
311
+ entityIds: entity ? [String(entity.id)] : [],
312
+ ...(tenancy && { tenancy }),
313
+ },
314
+ };
315
+ if (credentialId && String(callback.credential_id) !== String(credentialId)) {
316
+ // The provider account differs from the one being re-authorized,
317
+ // so a separate credential was stored.
318
+ outcome.previousCredentialId = String(credentialId);
319
+ }
320
+ return outcome;
321
+ }
322
+ }
323
+
324
+ module.exports = {
325
+ GetAuthorizationStep,
326
+ SubmitAuthorizationStep,
327
+ toAuthorizationError,
328
+ isMultiStep,
329
+ };
@@ -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