@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,247 @@
1
+ /**
2
+ * Which stored rows a caller may see and use (ADR-056 §3.4, §3.6).
3
+ *
4
+ * No I/O. One clause list (`ctx.clauses`) is what a repository is asked for and
5
+ * what `describeRecord` checks every returned row against, so a list and an
6
+ * access check cannot drift apart. The clauses are built once per request, when
7
+ * the access context is.
8
+ */
9
+
10
+ const { isPresent, idString, sameId } = require('../user/membership');
11
+ const { AUTH_SOURCES } = require('../user/user');
12
+ const { SCOPES, ACCESS, DEFAULT_ORG_ADMIN_ROLES } = require('./scope-resolution');
13
+
14
+ /**
15
+ * One owner/organization pair a repository ORs into a single query.
16
+ * `userId` is the owner's user id, and for an organization-owned row that is
17
+ * the ORGANIZATION user's id, not a person's. `organizationId: null` means "has
18
+ * no organization" (unset or null); otherwise the id of the organization.
19
+ *
20
+ * @typedef {Object} OwnershipClause
21
+ * @property {string} userId
22
+ * @property {(string|null)} organizationId
23
+ */
24
+
25
+ /**
26
+ * Whether the caller's own rows with no organization belong to the context this
27
+ * request acts in: always when it acts in no organization (today's behaviour),
28
+ * always for an entity under `individualAcross: 'all'`, and otherwise only in
29
+ * the person's legacy organization.
30
+ */
31
+ function unscopedRowsBelongHere(base, kind) {
32
+ return (
33
+ !base.activeOrgId ||
34
+ (kind === 'entity' && base.individualAcross === 'all') ||
35
+ sameId(base.legacyOrgId, base.activeOrgId)
36
+ );
37
+ }
38
+
39
+ /**
40
+ * What a caller may see, as owner/organization pairs:
41
+ *
42
+ * - the active organization's own rows (owner = the organization), whether
43
+ * they carry its id or, written before scoping, none;
44
+ * - the caller's rows in the active organization;
45
+ * - the caller's rows with no organization, when `unscopedRowsBelongHere`.
46
+ *
47
+ * `kind` is 'entity' or 'integration'; only entities may be
48
+ * organization-independent under 'all' (an integration always belongs to the
49
+ * organization it was created in).
50
+ *
51
+ * @returns {OwnershipClause[]}
52
+ */
53
+ function buildClauses(base, kind) {
54
+ const clauses = [];
55
+ if (base.activeOrgId) {
56
+ clauses.push({ userId: base.activeOrgId, organizationId: base.activeOrgId });
57
+ clauses.push({ userId: base.activeOrgId, organizationId: null });
58
+ }
59
+ if (base.callerId) {
60
+ if (base.activeOrgId) {
61
+ clauses.push({ userId: base.callerId, organizationId: base.activeOrgId });
62
+ }
63
+ if (unscopedRowsBelongHere(base, kind)) {
64
+ clauses.push({ userId: base.callerId, organizationId: null });
65
+ }
66
+ }
67
+ return clauses;
68
+ }
69
+
70
+ /**
71
+ * Whether the caller may manage organization-level things (create, change,
72
+ * delete), ADR-056 §3.6:
73
+ *
74
+ * - A shared-secret caller is the app's own backend and may.
75
+ * - A Frigg bearer token (`/user/login`) predates organization roles and
76
+ * carries none. It keeps its pre-roles behaviour and may, unless the app
77
+ * sets `user.orgAdminGateBearerTokens: true`; then it is judged like any
78
+ * other caller below.
79
+ * - Otherwise the role is the one the token carries; when it carries none,
80
+ * the role stored on the caller's membership of the active organization;
81
+ * when there is none, `MEMBER`. A missing role never means admin. An
82
+ * unknown source (a `User` built outside AuthenticateUser) is judged this
83
+ * way too.
84
+ *
85
+ * @param {{authSource: (string|null), orgRole: (string|null), membershipRole: (string|null)}} who
86
+ * @param {string[]} adminRoles - `user.orgAdminRoles`, compared case-insensitively
87
+ * @param {{gateBearerTokens?: boolean}} [options]
88
+ */
89
+ function canManageOrganization(
90
+ { authSource, orgRole, membershipRole },
91
+ adminRoles = DEFAULT_ORG_ADMIN_ROLES,
92
+ { gateBearerTokens = false } = {}
93
+ ) {
94
+ if (authSource === AUTH_SOURCES.SHARED_SECRET) return true;
95
+ if (authSource === AUTH_SOURCES.BEARER_TOKEN && !gateBearerTokens) return true;
96
+ let role = 'MEMBER';
97
+ if (isPresent(orgRole)) role = orgRole;
98
+ else if (isPresent(membershipRole)) role = membershipRole;
99
+ role = String(role).toUpperCase();
100
+ return adminRoles.some((admin) => String(admin).toUpperCase() === role);
101
+ }
102
+
103
+ /**
104
+ * Who the caller is, for ownership and visibility: their individual user, the
105
+ * organization this request acts in, the legacy `organizationId` pointer,
106
+ * whether they may manage organization-level things, and the clauses that
107
+ * follow from them (computed once).
108
+ *
109
+ * @param {Object} user - the authenticated `User` (or a `{ getId }` double)
110
+ * @param {{individualAcross?: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [options]
111
+ */
112
+ function accessContext(
113
+ user,
114
+ {
115
+ individualAcross = 'organization',
116
+ orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES,
117
+ gateBearerTokens = false,
118
+ } = {}
119
+ ) {
120
+ const hasUserModel = typeof user?.getIndividualUser === 'function';
121
+ const individual = hasUserModel ? user.getIndividualUser() : null;
122
+ const base = {
123
+ callerId: hasUserModel ? idString(individual?.id) : idString(user?.getId?.()),
124
+ activeOrgId:
125
+ typeof user?.getActiveOrganizationId === 'function'
126
+ ? idString(user.getActiveOrganizationId())
127
+ : null,
128
+ legacyOrgId: idString(individual?.organizationId),
129
+ individualAcross,
130
+ };
131
+ const who = {
132
+ authSource:
133
+ typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
134
+ orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
135
+ membershipRole:
136
+ typeof user?.getMembershipRole === 'function'
137
+ ? user.getMembershipRole()
138
+ : null,
139
+ };
140
+ return {
141
+ ...base,
142
+ canManageOrg: canManageOrganization(who, orgAdminRoles, {
143
+ gateBearerTokens,
144
+ }),
145
+ clauses: {
146
+ entity: buildClauses(base, 'entity'),
147
+ integration: buildClauses(base, 'integration'),
148
+ },
149
+ };
150
+ }
151
+
152
+ /** The clauses for `kind` ('entity' | 'integration'): what to ask the repository. */
153
+ const visibilityClauses = (ctx, kind) => ctx.clauses[kind];
154
+
155
+ const matchesClause = (record, clause) =>
156
+ sameId(record.userId, clause.userId) &&
157
+ (clause.organizationId === null
158
+ ? !isPresent(record.organizationId)
159
+ : sameId(record.organizationId, clause.organizationId));
160
+
161
+ /** The scope a stored row has for this caller, from its owner. */
162
+ function scopeOf(record, ctx) {
163
+ if (!isPresent(record?.userId)) return SCOPES.GLOBAL;
164
+ if (sameId(record.userId, ctx.activeOrgId)) return SCOPES.ORGANIZATION;
165
+ if (sameId(record.userId, ctx.callerId)) return SCOPES.INDIVIDUAL;
166
+ return null;
167
+ }
168
+
169
+ /** manage for the person's own; for the organization's, only an admin; use otherwise. */
170
+ const accessFor = (scope, ctx) => {
171
+ if (scope === SCOPES.INDIVIDUAL) return ACCESS.MANAGE;
172
+ if (scope === SCOPES.ORGANIZATION) {
173
+ return ctx.canManageOrg ? ACCESS.MANAGE : ACCESS.USE;
174
+ }
175
+ return ACCESS.USE;
176
+ };
177
+
178
+ /**
179
+ * Whether an access level lets the caller use a record: bind it, proxy through
180
+ * it (§3.4). `manage` includes `use`; `view` (metadata only) does not, and
181
+ * neither does anything unrecognised.
182
+ */
183
+ const grantsUse = (access) => access === ACCESS.USE || access === ACCESS.MANAGE;
184
+
185
+ /**
186
+ * What the caller may do with a record owned by `ownerId` that carries no
187
+ * organization of its own (a credential): manage the person's own, manage the
188
+ * active organization's only as an admin, nothing otherwise. Null when the
189
+ * record is not theirs.
190
+ */
191
+ function accessToOwner(ownerId, ctx) {
192
+ const scope = scopeOf({ userId: ownerId }, ctx);
193
+ if (scope !== SCOPES.INDIVIDUAL && scope !== SCOPES.ORGANIZATION) return null;
194
+ return accessFor(scope, ctx);
195
+ }
196
+
197
+ /**
198
+ * What a row is to this caller, or null when they may not see it.
199
+ * `{ scope, owner: {type, id}, organizationId, access }` (§3.4). A `global`
200
+ * row is never described: it is not the tenant's to see.
201
+ */
202
+ function describeRecord(record, ctx, kind) {
203
+ if (!record) return null;
204
+ const scope = scopeOf(record, ctx);
205
+ if (scope === null || scope === SCOPES.GLOBAL) return null;
206
+ if (!ctx.clauses[kind].some((clause) => matchesClause(record, clause))) {
207
+ return null;
208
+ }
209
+ return {
210
+ scope,
211
+ owner: { type: scope, id: idString(record.userId) },
212
+ organizationId:
213
+ scope === SCOPES.ORGANIZATION
214
+ ? idString(record.userId)
215
+ : idString(record.organizationId),
216
+ access: accessFor(scope, ctx),
217
+ };
218
+ }
219
+
220
+ /** `?owner=` values and the scope each one means. */
221
+ const LIST_OWNERS = Object.freeze({
222
+ me: SCOPES.INDIVIDUAL,
223
+ org: SCOPES.ORGANIZATION,
224
+ });
225
+
226
+ /** Whether a described row passes `?scope=` and `?owner=`. */
227
+ function matchesListFilters(description, { scope, owner } = {}) {
228
+ if (scope !== undefined && description.scope !== scope) return false;
229
+ if (owner !== undefined && description.owner.type !== LIST_OWNERS[owner]) {
230
+ return false;
231
+ }
232
+ return true;
233
+ }
234
+
235
+ module.exports = {
236
+ canManageOrganization,
237
+ accessToOwner,
238
+ accessContext,
239
+ unscopedRowsBelongHere,
240
+ visibilityClauses,
241
+ matchesClause,
242
+ scopeOf,
243
+ describeRecord,
244
+ grantsUse,
245
+ LIST_OWNERS,
246
+ matchesListFilters,
247
+ };
@@ -0,0 +1,45 @@
1
+ const { AuthorizationSession } = require('../../domain/authorization-session');
2
+
3
+ /** In-memory AuthorizationSession repository for tests. */
4
+ class InMemoryAuthorizationSessionRepository {
5
+ constructor() {
6
+ this.rows = new Map();
7
+ }
8
+
9
+ _copy(session) {
10
+ return new AuthorizationSession({ ...session, stepData: { ...session.stepData } });
11
+ }
12
+
13
+ async create(session) {
14
+ this.rows.set(session.sessionId, this._copy(session));
15
+ return this._copy(session);
16
+ }
17
+
18
+ async findBySessionId(sessionId) {
19
+ const row = this.rows.get(sessionId);
20
+ if (!row || row.isExpired()) return null;
21
+ return this._copy(row);
22
+ }
23
+
24
+ async update(session) {
25
+ this.rows.set(session.sessionId, this._copy(session));
26
+ return this._copy(session);
27
+ }
28
+
29
+ async deleteBySessionId(sessionId) {
30
+ return this.rows.delete(sessionId) ? 1 : 0;
31
+ }
32
+
33
+ async deleteExpired(now = new Date()) {
34
+ let removed = 0;
35
+ for (const [id, row] of this.rows) {
36
+ if (row.isExpired(now)) {
37
+ this.rows.delete(id);
38
+ removed++;
39
+ }
40
+ }
41
+ return removed;
42
+ }
43
+ }
44
+
45
+ module.exports = { InMemoryAuthorizationSessionRepository };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Test double helper: answers an owner/organization clause the way a database
3
+ * does, so in-memory repositories run the clauses a use case hands them.
4
+ * `clause.organizationId === null` matches a row with no organization (unset
5
+ * or null).
6
+ */
7
+ const same = (a, b) =>
8
+ a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b);
9
+
10
+ const matchesClause = (row, clause) =>
11
+ same(row.userId, clause.userId) &&
12
+ (clause.organizationId === null
13
+ ? row.organizationId === null || row.organizationId === undefined
14
+ : same(row.organizationId, clause.organizationId));
15
+
16
+ const matchesAnyClause = (row, clauses) => clauses.some((clause) => matchesClause(row, clause));
17
+
18
+ module.exports = { same, matchesClause, matchesAnyClause };
@@ -0,0 +1,61 @@
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, fixedScope?: string}} params
23
+ * `fixedScope`: a re-authorization keeps the scope its connection has
24
+ */
25
+ resolveOwnership({ user, entityType, scope, fixedScope }) {
26
+ if (fixedScope) return this.scopePolicy.ownershipForScope({ scope: fixedScope, user });
27
+ return this.scopePolicy.resolveOwnership({
28
+ entityType,
29
+ requestScope: scope,
30
+ user,
31
+ });
32
+ }
33
+
34
+ /**
35
+ * @param {Object} params
36
+ * @param {Object} params.user - authenticated User
37
+ * @param {string} params.entityType
38
+ * @param {Object} params.data - what the module's authorization needs
39
+ * @param {string} [params.scope] - the scope the request asks for
40
+ * @param {Object} [params.ownership] - from `resolveOwnership`, when the
41
+ * caller needed it earlier (a multi-step flow does)
42
+ * @returns {Promise<{result: Object, ownership: Object}>} the callback's
43
+ * `{ credential_id, entity_id, type }` and the ownership it ran with
44
+ */
45
+ async execute({ user, entityType, data, scope, ownership }) {
46
+ const resolved = ownership ?? this.resolveOwnership({ user, entityType, scope });
47
+ const result = await this.processAuthorizationCallback.execute(
48
+ resolved.ownerId,
49
+ entityType,
50
+ data,
51
+ {
52
+ scope: resolved.scope,
53
+ organizationId: resolved.organizationId,
54
+ legacyOrgId: resolved.legacyOrgId,
55
+ }
56
+ );
57
+ return { result, ownership: resolved };
58
+ }
59
+ }
60
+
61
+ module.exports = { AuthorizeEntityForUser };
@@ -0,0 +1,113 @@
1
+ const Boom = require('@hapi/boom');
2
+ const { Module } = require('../module');
3
+
4
+ const sameId = (a, b) =>
5
+ a !== undefined && a !== null && b !== undefined && b !== null &&
6
+ String(a) === String(b);
7
+
8
+ /**
9
+ * Creates (or finds) the entity for a credential the caller already holds:
10
+ * the v1 `POST /api/entity`. The owner and organization follow the scope of
11
+ * `entityType`, exactly as an authorization does (ADR-056 §3.2, §3.6), so the
12
+ * owner is decided by `AuthorizeEntityForUser.resolveOwnership` and never by
13
+ * `user.getId()`. Whatever that resolution refuses (a missing identity, and
14
+ * an organization-level connection by a non-admin) is refused here too.
15
+ *
16
+ * The credential must belong to that owner: an organization entity is built
17
+ * on the organization's credential, an individual one on the person's own.
18
+ */
19
+ class CreateEntityFromCredential {
20
+ /**
21
+ * @param {Object} params
22
+ * @param {import('./authorize-entity-for-user').AuthorizeEntityForUser} params.authorizeEntityForUser
23
+ * @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback
24
+ * @param {Object} params.credentialRepository
25
+ * @param {Array<Object>} params.moduleDefinitions
26
+ * @param {Function} [params.createModule] - builds a Module; injectable for tests
27
+ */
28
+ constructor({
29
+ authorizeEntityForUser,
30
+ processAuthorizationCallback,
31
+ credentialRepository,
32
+ moduleDefinitions,
33
+ createModule,
34
+ }) {
35
+ this.authorizeEntityForUser = authorizeEntityForUser;
36
+ this.processAuthorizationCallback = processAuthorizationCallback;
37
+ this.credentialRepository = credentialRepository;
38
+ this.moduleDefinitions = moduleDefinitions || [];
39
+ this.createModule = createModule || ((params) => Module.create(params));
40
+ }
41
+
42
+ /**
43
+ * @param {Object} params
44
+ * @param {Object} params.user - authenticated User
45
+ * @param {string} params.entityType
46
+ * @param {string} params.credentialId
47
+ * @returns {Promise<Object>} the persisted entity
48
+ */
49
+ async execute({ user, entityType, credentialId }) {
50
+ const definition = this.moduleDefinitions.find(
51
+ (def) =>
52
+ def.moduleName === entityType ||
53
+ (typeof def.getName === 'function' && def.getName() === entityType)
54
+ );
55
+ if (!definition) {
56
+ throw Boom.badRequest(`Unknown entity type ${entityType}`, {
57
+ code: 'ENTITY_TYPE_NOT_FOUND',
58
+ });
59
+ }
60
+
61
+ const ownership = this.authorizeEntityForUser.resolveOwnership({
62
+ user,
63
+ entityType,
64
+ });
65
+
66
+ const credential =
67
+ await this.credentialRepository.findCredentialById(credentialId);
68
+ // A missing credential and someone else's are the same answer, so ids
69
+ // cannot be probed.
70
+ if (!credential || !sameId(credential.userId, ownership.ownerId)) {
71
+ throw Boom.badRequest('Invalid credential ID', {
72
+ code: 'INVALID_CREDENTIAL',
73
+ });
74
+ }
75
+
76
+ // Built for the owner, with the credential loaded (the repository
77
+ // returns its tokens flat; a Module reads them from `data`), so the
78
+ // API can call the provider and a token refresh during
79
+ // getEntityDetails updates the owner's credential.
80
+ const { id, userId, externalId, authIsValid, ...data } = credential;
81
+ const module = await this.createModule({
82
+ userId: ownership.ownerId,
83
+ definition,
84
+ entity: {
85
+ credential: { id, userId, externalId, authIsValid, data },
86
+ },
87
+ });
88
+ const entityDetails =
89
+ await definition.requiredAuthMethods.getEntityDetails(
90
+ module.api,
91
+ null,
92
+ null,
93
+ ownership.ownerId
94
+ );
95
+ Object.assign(
96
+ entityDetails.details,
97
+ module.apiParamsFromEntity(module.api)
98
+ );
99
+
100
+ return this.processAuthorizationCallback.findOrCreateEntity(
101
+ entityDetails,
102
+ definition.moduleName,
103
+ credential.id,
104
+ {
105
+ scope: ownership.scope,
106
+ organizationId: ownership.organizationId,
107
+ legacyOrgId: ownership.legacyOrgId,
108
+ }
109
+ );
110
+ }
111
+ }
112
+
113
+ module.exports = { CreateEntityFromCredential };
@@ -0,0 +1,162 @@
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');
8
+ const { assertCanManage } = require('./manage-access');
9
+
10
+ /**
11
+ * Management API v2 entity operations. Each one starts from
12
+ * GetEntityModuleForUser, so ownership is checked the same way everywhere
13
+ * (missing or foreign entity = 404).
14
+ */
15
+
16
+ /**
17
+ * The entities the caller may see in the active organization (ADR-056 §3.4,
18
+ * §3.6): the organization's own, and the caller's individual ones by the
19
+ * visibility rule. Each carries `tenancy` ({scope, owner, organizationId,
20
+ * access}); the stored entity is otherwise untouched.
21
+ */
22
+ class ListEntitiesForUser {
23
+ constructor({ moduleRepository, scopePolicy }) {
24
+ this.moduleRepository = moduleRepository;
25
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
26
+ }
27
+
28
+ /**
29
+ * @param {Object} user - authenticated User
30
+ * @param {{scope?: string, owner?: string}} [filters] - `?scope=` and `?owner=`
31
+ */
32
+ async execute(user, filters = {}) {
33
+ const ctx = this.scopePolicy.accessContext(user);
34
+ const entities = await this.moduleRepository.findEntitiesByOwnership(
35
+ visibilityClauses(ctx, 'entity')
36
+ );
37
+ const listed = [];
38
+ for (const entity of Array.isArray(entities) ? entities : []) {
39
+ // The query is the filter; this is the same rule applied again,
40
+ // so a repository that returns too much cannot widen the list.
41
+ const tenancy = describeRecord(entity, ctx, 'entity');
42
+ if (tenancy && matchesListFilters(tenancy, filters)) {
43
+ listed.push({ ...entity, tenancy });
44
+ }
45
+ }
46
+ return listed;
47
+ }
48
+ }
49
+
50
+ /** One entity the caller may use, with its `tenancy`. */
51
+ class GetEntityForUser {
52
+ constructor({ getEntityModuleForUser }) {
53
+ this.getEntityModuleForUser = getEntityModuleForUser;
54
+ }
55
+
56
+ async execute(entityId, user) {
57
+ const entity = await this.getEntityModuleForUser.loadOwnedEntity(
58
+ entityId,
59
+ user
60
+ );
61
+ return {
62
+ ...entity,
63
+ tenancy: this.getEntityModuleForUser.describe(entity, user),
64
+ };
65
+ }
66
+ }
67
+
68
+ /** Runs the module's testAuthRequest against the stored credential. */
69
+ class TestEntityAuthForUser {
70
+ constructor({ getEntityModuleForUser }) {
71
+ this.getEntityModuleForUser = getEntityModuleForUser;
72
+ }
73
+
74
+ /** @returns {Promise<boolean>} */
75
+ async execute(entityId, user) {
76
+ const { module } = await this.getEntityModuleForUser.execute(
77
+ entityId,
78
+ user
79
+ );
80
+ return Boolean(await module.testAuth());
81
+ }
82
+ }
83
+
84
+ /** The module's entity options (e.g. objects a user can pick). */
85
+ class GetEntityOptionsForUser {
86
+ constructor({ getEntityModuleForUser }) {
87
+ this.getEntityModuleForUser = getEntityModuleForUser;
88
+ }
89
+
90
+ async execute(entityId, user) {
91
+ const { module } = await this.getEntityModuleForUser.execute(
92
+ entityId,
93
+ user
94
+ );
95
+ return module.getEntityOptions();
96
+ }
97
+ }
98
+
99
+ /** Refreshes the module's entity options with caller input, then returns them. */
100
+ class RefreshEntityOptionsForUser {
101
+ constructor({ getEntityModuleForUser }) {
102
+ this.getEntityModuleForUser = getEntityModuleForUser;
103
+ }
104
+
105
+ async execute(entityId, user, options = {}) {
106
+ const { module } = await this.getEntityModuleForUser.execute(
107
+ entityId,
108
+ user
109
+ );
110
+ return module.refreshEntityOptions(options);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Deletes an entity the caller may manage (the owner; for an organization
116
+ * entity, an organization admin). Refuses with 409 while integrations still
117
+ * use it, so an integration never points at a missing entity. The credential
118
+ * is left alone (others may share it); delete it through /api/v2/credentials.
119
+ */
120
+ class DeleteEntityForUser {
121
+ constructor({ getEntityModuleForUser, moduleRepository, integrationRepository }) {
122
+ this.getEntityModuleForUser = getEntityModuleForUser;
123
+ this.moduleRepository = moduleRepository;
124
+ this.integrationRepository = integrationRepository;
125
+ }
126
+
127
+ async execute(entityId, user) {
128
+ const entity = await this.getEntityModuleForUser.loadOwnedEntity(
129
+ entityId,
130
+ user
131
+ );
132
+ assertCanManage(
133
+ this.getEntityModuleForUser.describe(entity, user)?.access,
134
+ `entity ${entity.id}`
135
+ );
136
+ const integrations =
137
+ (await this.integrationRepository.findIntegrationsByEntityId(
138
+ entity.id
139
+ )) || [];
140
+ if (integrations.length > 0) {
141
+ throw Boom.conflict(
142
+ `Entity ${entity.id} is used by ${integrations.length} integration(s); delete them first`,
143
+ {
144
+ code: 'ENTITY_IN_USE',
145
+ details: {
146
+ integrationIds: integrations.map((i) => String(i.id)),
147
+ },
148
+ }
149
+ );
150
+ }
151
+ await this.moduleRepository.deleteEntity(entity.id);
152
+ }
153
+ }
154
+
155
+ module.exports = {
156
+ ListEntitiesForUser,
157
+ GetEntityForUser,
158
+ TestEntityAuthForUser,
159
+ GetEntityOptionsForUser,
160
+ RefreshEntityOptionsForUser,
161
+ DeleteEntityForUser,
162
+ };