@friggframework/core 2.0.0--canary.679.a2bcc2f.0 → 2.0.0--canary.686.87e9875.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 (136) hide show
  1. package/CLAUDE.md +25 -0
  2. package/README.md +105 -0
  3. package/application/commands/integration-commands.js +2 -2
  4. package/application/commands/user-commands.js +17 -5
  5. package/core/Worker.js +31 -7
  6. package/credential/use-cases/credential-operations-for-user.js +110 -25
  7. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  8. package/database/config.js +48 -24
  9. package/database/documentdb-utils.js +6 -1
  10. package/database/encryption/encryption-schema-registry.js +80 -12
  11. package/database/organization-where.js +18 -0
  12. package/database/prisma.js +4 -2
  13. package/database/unique-violation.js +25 -0
  14. package/extensions/bootstrap-runner.js +43 -0
  15. package/extensions/extension-loader.js +167 -0
  16. package/extensions/index.js +41 -0
  17. package/extensions/initialize-app.js +123 -0
  18. package/extensions/route-mounter.js +103 -0
  19. package/extensions/schema-composer.js +188 -0
  20. package/generated/prisma-mongodb/edge.js +27 -4
  21. package/generated/prisma-mongodb/index-browser.js +24 -1
  22. package/generated/prisma-mongodb/index.d.ts +3227 -47
  23. package/generated/prisma-mongodb/index.js +27 -4
  24. package/generated/prisma-mongodb/package.json +1 -1
  25. package/generated/prisma-mongodb/schema.prisma +65 -0
  26. package/generated/prisma-mongodb/wasm.js +27 -4
  27. package/generated/prisma-postgresql/edge.js +27 -4
  28. package/generated/prisma-postgresql/index-browser.js +24 -1
  29. package/generated/prisma-postgresql/index.d.ts +3519 -67
  30. package/generated/prisma-postgresql/index.js +27 -4
  31. package/generated/prisma-postgresql/package.json +1 -1
  32. package/generated/prisma-postgresql/schema.prisma +61 -0
  33. package/generated/prisma-postgresql/wasm.js +27 -4
  34. package/handlers/app-definition-loader.js +93 -12
  35. package/handlers/routers/auth.js +40 -12
  36. package/handlers/routers/integration-defined-routers.js +144 -92
  37. package/handlers/routers/integration-webhook-routers.js +102 -64
  38. package/index.js +58 -1
  39. package/infrastructure/scheduler/index.js +19 -6
  40. package/infrastructure/scheduler/netlify-scheduler-adapter.js +185 -0
  41. package/infrastructure/scheduler/scheduler-service-factory.js +36 -7
  42. package/integrations/integration-router.js +115 -73
  43. package/integrations/repositories/integration-not-found.js +14 -0
  44. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  45. package/integrations/repositories/integration-repository-interface.js +17 -1
  46. package/integrations/repositories/integration-repository-mongo.js +59 -76
  47. package/integrations/repositories/integration-repository-postgres.js +62 -76
  48. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  49. package/integrations/use-cases/create-integration.js +55 -20
  50. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  51. package/integrations/use-cases/index.js +5 -1
  52. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  53. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  54. package/management-api/management-api-config.js +71 -2
  55. package/management-api/openapi/components.js +40 -0
  56. package/management-api/openapi/v2-operations.js +16 -4
  57. package/management-api/v2/dependencies.js +40 -15
  58. package/management-api/v2/dto.js +20 -0
  59. package/management-api/v2/handlers/authorization.js +1 -0
  60. package/management-api/v2/handlers/credentials.js +6 -2
  61. package/management-api/v2/handlers/entities.js +10 -7
  62. package/management-api/v2/handlers/integrations.js +6 -3
  63. package/management-api/v2/request.js +32 -1
  64. package/modules/entity-scope.js +15 -0
  65. package/modules/index.js +8 -0
  66. package/modules/module-factory.js +12 -0
  67. package/modules/module.js +35 -2
  68. package/modules/repositories/entity-not-found.js +18 -0
  69. package/modules/repositories/module-repository-documentdb.js +51 -6
  70. package/modules/repositories/module-repository-interface.js +19 -2
  71. package/modules/repositories/module-repository-mongo.js +67 -74
  72. package/modules/repositories/module-repository-postgres.js +66 -74
  73. package/modules/repositories/module-repository.js +2 -1
  74. package/modules/scope-config.js +192 -0
  75. package/modules/scope-policy.js +112 -0
  76. package/modules/scope-resolution.js +288 -0
  77. package/modules/scope-visibility.js +247 -0
  78. package/modules/tests/doubles/ownership-matching.js +18 -0
  79. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  80. package/modules/use-cases/create-entity-from-credential.js +113 -0
  81. package/modules/use-cases/entity-operations-for-user.js +50 -13
  82. package/modules/use-cases/entity-types.js +31 -7
  83. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  84. package/modules/use-cases/get-entities-for-user.js +16 -5
  85. package/modules/use-cases/get-entity-module-for-user.js +32 -5
  86. package/modules/use-cases/get-entity-options-by-id.js +9 -3
  87. package/modules/use-cases/get-module.js +9 -3
  88. package/modules/use-cases/manage-access.js +21 -0
  89. package/modules/use-cases/multi-step-authorization.js +39 -12
  90. package/modules/use-cases/process-authorization-callback.js +52 -18
  91. package/modules/use-cases/refresh-entity-options.js +9 -3
  92. package/modules/use-cases/test-module-auth.js +9 -3
  93. package/package.json +6 -5
  94. package/prisma-mongodb/schema.prisma +65 -0
  95. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  96. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  97. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  98. package/prisma-postgresql/schema.prisma +61 -0
  99. package/providers/index.js +13 -0
  100. package/providers/resolve-provider.js +93 -0
  101. package/queues/index.js +25 -0
  102. package/queues/providers/index.js +11 -0
  103. package/queues/providers/netlify-background-provider.js +76 -0
  104. package/queues/providers/qstash-queue-provider.js +122 -0
  105. package/queues/providers/sqs-queue-provider.js +61 -0
  106. package/queues/queue-client-interface.js +55 -0
  107. package/queues/queue-provider-factory.js +101 -0
  108. package/queues/queue-provider.js +46 -0
  109. package/queues/sqs-queue-client.js +81 -0
  110. package/types/index.d.ts +1 -0
  111. package/types/modules/index.d.ts +129 -0
  112. package/types/user/index.d.ts +134 -0
  113. package/user/adopter-jwt-config.js +177 -0
  114. package/user/adopter-jwt-errors.js +59 -0
  115. package/user/adopter-token-verifier.js +122 -0
  116. package/user/app-user-helpers.js +97 -0
  117. package/user/create-user-authentication.js +64 -0
  118. package/user/membership.js +51 -0
  119. package/user/repositories/organization-membership-record.js +19 -0
  120. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  121. package/user/repositories/organization-membership-repository-factory.js +42 -0
  122. package/user/repositories/organization-membership-repository-interface.js +57 -0
  123. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  124. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  125. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  126. package/user/repositories/user-repository-documentdb.js +27 -2
  127. package/user/repositories/user-repository-interface.js +18 -0
  128. package/user/repositories/user-repository-mongo.js +24 -0
  129. package/user/repositories/user-repository-postgres.js +18 -0
  130. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  131. package/user/use-cases/authenticate-user.js +7 -1
  132. package/user/use-cases/ensure-organization-membership.js +92 -0
  133. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  134. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  135. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  136. package/user/user.js +64 -1
@@ -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) => new Module(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 = 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 };
@@ -1,4 +1,11 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const {
3
+ describeRecord,
4
+ visibilityClauses,
5
+ matchesListFilters,
6
+ } = require('../scope-visibility');
7
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
8
+ const { assertCanManage } = require('./manage-access');
2
9
 
3
10
  /**
4
11
  * Management API v2 entity operations. Each one starts from
@@ -6,29 +13,55 @@ const Boom = require('@hapi/boom');
6
13
  * (missing or foreign entity = 404).
7
14
  */
8
15
 
9
- /** The caller's connected entities. */
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
+ */
10
22
  class ListEntitiesForUser {
11
- constructor({ moduleRepository }) {
23
+ constructor({ moduleRepository, scopePolicy }) {
12
24
  this.moduleRepository = moduleRepository;
25
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
13
26
  }
14
27
 
15
- /** @param {Object} user - authenticated User */
16
- async execute(user) {
17
- const entities = await this.moduleRepository.findEntitiesByUserId(
18
- user.getId()
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')
19
36
  );
20
- return Array.isArray(entities) ? entities : [];
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;
21
47
  }
22
48
  }
23
49
 
24
- /** One entity the caller owns. */
50
+ /** One entity the caller may use, with its `tenancy`. */
25
51
  class GetEntityForUser {
26
52
  constructor({ getEntityModuleForUser }) {
27
53
  this.getEntityModuleForUser = getEntityModuleForUser;
28
54
  }
29
55
 
30
56
  async execute(entityId, user) {
31
- return this.getEntityModuleForUser.loadOwnedEntity(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
+ };
32
65
  }
33
66
  }
34
67
 
@@ -79,10 +112,10 @@ class RefreshEntityOptionsForUser {
79
112
  }
80
113
 
81
114
  /**
82
- * Deletes an entity the caller owns. Refuses with 409 while integrations
83
- * still use it, so an integration never points at a missing entity. The
84
- * credential is left alone (others may share it); delete it through
85
- * /api/v2/credentials.
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.
86
119
  */
87
120
  class DeleteEntityForUser {
88
121
  constructor({ getEntityModuleForUser, moduleRepository, integrationRepository }) {
@@ -96,6 +129,10 @@ class DeleteEntityForUser {
96
129
  entityId,
97
130
  user
98
131
  );
132
+ assertCanManage(
133
+ this.getEntityModuleForUser.describe(entity, user)?.access,
134
+ `entity ${entity.id}`
135
+ );
99
136
  const integrations =
100
137
  (await this.integrationRepository.findIntegrationsByEntityId(
101
138
  entity.id
@@ -59,10 +59,31 @@ function findDefinition(moduleDefinitions, entityType) {
59
59
  return definition;
60
60
  }
61
61
 
62
- const describeEntry = (name, entry) => ({
62
+ /**
63
+ * With a scope policy the entry also says which scope a new connection gets
64
+ * and which a request may choose (ADR-056 §3.1), so a UI can offer the choice.
65
+ * `global` is never offered: a tenant request cannot create one.
66
+ */
67
+ const scopeOf = (scopePolicy, name, user) => {
68
+ if (!scopePolicy) return {};
69
+ const { scope, source, allow } = scopePolicy.resolveScope({
70
+ entityType: name,
71
+ user,
72
+ });
73
+ return {
74
+ scope: {
75
+ default: scope,
76
+ allow: allow.filter((value) => value !== 'global'),
77
+ source,
78
+ },
79
+ };
80
+ };
81
+
82
+ const describeEntry = (name, entry, scopePolicy, user) => ({
63
83
  // The registry key is the name the API uses, whatever the definition spells.
64
84
  ...describeEntityType(entry.definition, name),
65
85
  usedBy: usedByOf(entry),
86
+ ...scopeOf(scopePolicy, name, user),
66
87
  });
67
88
 
68
89
  function requireRegistry(entityTypeRegistry) {
@@ -78,31 +99,34 @@ function requireRegistry(entityTypeRegistry) {
78
99
  * bind each type; it is empty for a registry-only type.
79
100
  */
80
101
  class ListEntityTypes {
81
- constructor({ entityTypeRegistry }) {
102
+ constructor({ entityTypeRegistry, scopePolicy }) {
82
103
  this.registry = requireRegistry(entityTypeRegistry);
104
+ this.scopePolicy = scopePolicy;
83
105
  }
84
106
 
85
- async execute() {
107
+ /** @param {{user?: Object}} [params] - the caller, for the level-6 scope default */
108
+ async execute({ user } = {}) {
86
109
  return [...this.registry]
87
- .map(([name, entry]) => describeEntry(name, entry))
110
+ .map(([name, entry]) => describeEntry(name, entry, this.scopePolicy, user))
88
111
  .sort((a, b) => a.name.localeCompare(b.name));
89
112
  }
90
113
  }
91
114
 
92
115
  /** Describes one entity type, looked up by its registry key; 404 when absent. */
93
116
  class GetEntityType {
94
- constructor({ entityTypeRegistry }) {
117
+ constructor({ entityTypeRegistry, scopePolicy }) {
95
118
  this.registry = requireRegistry(entityTypeRegistry);
119
+ this.scopePolicy = scopePolicy;
96
120
  }
97
121
 
98
- async execute(entityType) {
122
+ async execute(entityType, { user } = {}) {
99
123
  const entry = this.registry.get(entityType);
100
124
  if (!entry) {
101
125
  throw Boom.notFound(`Entity type '${entityType}' not found`, {
102
126
  code: 'ENTITY_TYPE_NOT_FOUND',
103
127
  });
104
128
  }
105
- return describeEntry(entityType, entry);
129
+ return describeEntry(entityType, entry, this.scopePolicy, user);
106
130
  }
107
131
  }
108
132
 
@@ -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;
@@ -1,8 +1,11 @@
1
1
  const { Module } = require('../module');
2
2
  const { mapModuleClassToModuleDTO } = require('../utils/map-module-dto');
3
+ const { describeRecord, visibilityClauses } = require('../scope-visibility');
4
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
3
5
 
4
6
  class GetEntitiesForUser {
5
- constructor({ moduleRepository, moduleDefinitions }) {
7
+ constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
8
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
6
9
  this.moduleRepository = moduleRepository;
7
10
 
8
11
  this.definitionMap = new Map();
@@ -11,16 +14,24 @@ class GetEntitiesForUser {
11
14
  }
12
15
  }
13
16
 
14
- async execute(userId) {
15
- const entities = await this.moduleRepository.findEntitiesByUserId(
16
- userId
17
+ /**
18
+ * The entities a `User` may see in the active organization (ADR-056 §3.6).
19
+ * @param {Object} user - authenticated User
20
+ */
21
+ async execute(user) {
22
+ const ctx = this.scopePolicy.accessContext(user);
23
+ const found = await this.moduleRepository.findEntitiesByOwnership(
24
+ visibilityClauses(ctx, 'entity')
25
+ );
26
+ const entities = (found || []).filter((entity) =>
27
+ describeRecord(entity, ctx, 'entity')
17
28
  );
18
29
 
19
30
  return entities.map((entity) => {
20
31
  const definition = this.definitionMap.get(entity.moduleName);
21
32
 
22
33
  const moduleInstance = new Module({
23
- userId,
34
+ userId: entity.userId,
24
35
  definition: definition,
25
36
  entity: entity,
26
37
  });
@@ -1,8 +1,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
  */
@@ -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;
@@ -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
 
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.
60
66
  const module = new Module({
61
- userId,
67
+ userId: entity.userId ?? userId,
62
68
  entity,
63
69
  definition: moduleDefinition,
64
70
  });
@@ -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
 
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.
56
62
  const module = new Module({
57
- userId,
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 };