@friggframework/core 2.0.0--canary.681.2df9ef0.0 → 2.0.0--canary.683.f45da33.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 (83) hide show
  1. package/README.md +37 -1
  2. package/application/commands/integration-commands.js +20 -10
  3. package/application/index.js +6 -2
  4. package/credential/use-cases/credential-operations-for-user.js +9 -1
  5. package/credential/use-cases/reauthorize-credential-for-user.js +14 -4
  6. package/database/encryption/encryption-schema-registry.js +7 -0
  7. package/database/organization-where.js +18 -0
  8. package/generated/prisma-mongodb/edge.js +5 -3
  9. package/generated/prisma-mongodb/index-browser.js +2 -0
  10. package/generated/prisma-mongodb/index.d.ts +115 -2
  11. package/generated/prisma-mongodb/index.js +5 -3
  12. package/generated/prisma-mongodb/package.json +1 -1
  13. package/generated/prisma-mongodb/schema.prisma +15 -0
  14. package/generated/prisma-mongodb/wasm.js +5 -3
  15. package/generated/prisma-postgresql/edge.js +5 -3
  16. package/generated/prisma-postgresql/index-browser.js +2 -0
  17. package/generated/prisma-postgresql/index.d.ts +131 -2
  18. package/generated/prisma-postgresql/index.js +5 -3
  19. package/generated/prisma-postgresql/package.json +1 -1
  20. package/generated/prisma-postgresql/schema.prisma +15 -0
  21. package/generated/prisma-postgresql/wasm.js +5 -3
  22. package/handlers/app-definition-loader.js +25 -8
  23. package/handlers/backend-utils.js +31 -25
  24. package/handlers/routers/health.js +1 -4
  25. package/handlers/workers/integration-defined-workers.js +5 -2
  26. package/index.js +10 -2
  27. package/integrations/index.js +0 -4
  28. package/integrations/integration-router.js +101 -52
  29. package/integrations/repositories/integration-not-found.js +14 -0
  30. package/integrations/repositories/integration-repository-documentdb.js +34 -3
  31. package/integrations/repositories/integration-repository-interface.js +17 -1
  32. package/integrations/repositories/integration-repository-mongo.js +59 -76
  33. package/integrations/repositories/integration-repository-postgres.js +62 -76
  34. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  35. package/integrations/use-cases/create-integration.js +55 -20
  36. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  37. package/integrations/use-cases/index.js +5 -1
  38. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  39. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  40. package/integrations/utils/map-integration-dto.js +1 -15
  41. package/management-api/management-api-config.js +3 -0
  42. package/management-api/openapi/components.js +46 -1
  43. package/management-api/openapi/v2-operations.js +14 -1
  44. package/management-api/v2/dependencies.js +58 -14
  45. package/management-api/v2/dto.js +17 -0
  46. package/management-api/v2/handlers/authorization.js +1 -0
  47. package/management-api/v2/handlers/entities.js +10 -7
  48. package/management-api/v2/handlers/integrations.js +6 -3
  49. package/management-api/v2/request.js +32 -1
  50. package/modules/entity-scope.js +15 -0
  51. package/modules/entity-type-registry.js +311 -0
  52. package/modules/index.js +14 -0
  53. package/modules/repositories/module-repository-documentdb.js +41 -1
  54. package/modules/repositories/module-repository-interface.js +19 -2
  55. package/modules/repositories/module-repository-mongo.js +64 -73
  56. package/modules/repositories/module-repository-postgres.js +63 -73
  57. package/modules/scope-config.js +177 -0
  58. package/modules/scope-policy.js +96 -0
  59. package/modules/scope-resolution.js +288 -0
  60. package/modules/scope-visibility.js +212 -0
  61. package/modules/tests/doubles/ownership-matching.js +18 -0
  62. package/modules/use-cases/authorize-entity-for-user.js +59 -0
  63. package/modules/use-cases/entity-operations-for-user.js +50 -13
  64. package/modules/use-cases/entity-types.js +62 -14
  65. package/modules/use-cases/execute-entity-proxy-request.js +15 -0
  66. package/modules/use-cases/get-entities-for-user.js +16 -5
  67. package/modules/use-cases/get-entity-module-for-user.js +23 -3
  68. package/modules/use-cases/get-entity-options-by-id.js +5 -2
  69. package/modules/use-cases/get-module.js +5 -2
  70. package/modules/use-cases/manage-access.js +21 -0
  71. package/modules/use-cases/multi-step-authorization.js +36 -12
  72. package/modules/use-cases/process-authorization-callback.js +52 -18
  73. package/modules/use-cases/refresh-entity-options.js +5 -2
  74. package/modules/use-cases/test-module-auth.js +5 -2
  75. package/package.json +5 -5
  76. package/prisma-mongodb/schema.prisma +15 -0
  77. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  78. package/prisma-postgresql/schema.prisma +15 -0
  79. package/types/index.d.ts +1 -0
  80. package/types/modules/index.d.ts +145 -0
  81. package/types/user/index.d.ts +7 -0
  82. package/user/use-cases/authenticate-user.js +7 -1
  83. package/user/user.js +24 -1
@@ -0,0 +1,212 @@
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). A shared-secret caller is the app's own backend and may. A person
73
+ * with a token has the role the token carries, and `MEMBER` when it carries
74
+ * none: a missing role never means admin. An unknown source (a `User` built
75
+ * outside AuthenticateUser) is treated like a token.
76
+ *
77
+ * @param {{authSource: (string|null), orgRole: (string|null)}} who
78
+ * @param {string[]} adminRoles - `user.orgAdminRoles`, compared case-insensitively
79
+ */
80
+ function canManageOrganization({ authSource, orgRole }, adminRoles = DEFAULT_ORG_ADMIN_ROLES) {
81
+ if (authSource === AUTH_SOURCES.SHARED_SECRET) return true;
82
+ const role = String(isPresent(orgRole) ? orgRole : 'MEMBER').toUpperCase();
83
+ return adminRoles.some((admin) => String(admin).toUpperCase() === role);
84
+ }
85
+
86
+ /**
87
+ * Who the caller is, for ownership and visibility: their individual user, the
88
+ * organization this request acts in, the legacy `organizationId` pointer,
89
+ * whether they may manage organization-level things, and the clauses that
90
+ * follow from them (computed once).
91
+ *
92
+ * @param {Object} user - the authenticated `User` (or a `{ getId }` double)
93
+ * @param {{individualAcross?: string, orgAdminRoles?: string[]}} [options]
94
+ */
95
+ function accessContext(
96
+ user,
97
+ { individualAcross = 'organization', orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES } = {}
98
+ ) {
99
+ const hasUserModel = typeof user?.getIndividualUser === 'function';
100
+ const individual = hasUserModel ? user.getIndividualUser() : null;
101
+ const base = {
102
+ callerId: hasUserModel ? idString(individual?.id) : idString(user?.getId?.()),
103
+ activeOrgId:
104
+ typeof user?.getActiveOrganizationId === 'function'
105
+ ? idString(user.getActiveOrganizationId())
106
+ : null,
107
+ legacyOrgId: idString(individual?.organizationId),
108
+ individualAcross,
109
+ };
110
+ const who = {
111
+ authSource:
112
+ typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
113
+ orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
114
+ };
115
+ return {
116
+ ...base,
117
+ canManageOrg: canManageOrganization(who, orgAdminRoles),
118
+ clauses: {
119
+ entity: buildClauses(base, 'entity'),
120
+ integration: buildClauses(base, 'integration'),
121
+ },
122
+ };
123
+ }
124
+
125
+ /** The clauses for `kind` ('entity' | 'integration'): what to ask the repository. */
126
+ const visibilityClauses = (ctx, kind) => ctx.clauses[kind];
127
+
128
+ const matchesClause = (record, clause) =>
129
+ sameId(record.userId, clause.userId) &&
130
+ (clause.organizationId === null
131
+ ? !isPresent(record.organizationId)
132
+ : sameId(record.organizationId, clause.organizationId));
133
+
134
+ /** The scope a stored row has for this caller, from its owner. */
135
+ function scopeOf(record, ctx) {
136
+ if (!isPresent(record?.userId)) return SCOPES.GLOBAL;
137
+ if (sameId(record.userId, ctx.activeOrgId)) return SCOPES.ORGANIZATION;
138
+ if (sameId(record.userId, ctx.callerId)) return SCOPES.INDIVIDUAL;
139
+ return null;
140
+ }
141
+
142
+ /** manage for the person's own; for the organization's, only an admin; use otherwise. */
143
+ const accessFor = (scope, ctx) => {
144
+ if (scope === SCOPES.INDIVIDUAL) return ACCESS.MANAGE;
145
+ if (scope === SCOPES.ORGANIZATION) {
146
+ return ctx.canManageOrg ? ACCESS.MANAGE : ACCESS.USE;
147
+ }
148
+ return ACCESS.USE;
149
+ };
150
+
151
+ /**
152
+ * What the caller may do with a record owned by `ownerId` that carries no
153
+ * organization of its own (a credential): manage the person's own, manage the
154
+ * active organization's only as an admin, nothing otherwise. Null when the
155
+ * record is not theirs.
156
+ */
157
+ function accessToOwner(ownerId, ctx) {
158
+ const scope = scopeOf({ userId: ownerId }, ctx);
159
+ if (scope !== SCOPES.INDIVIDUAL && scope !== SCOPES.ORGANIZATION) return null;
160
+ return accessFor(scope, ctx);
161
+ }
162
+
163
+ /**
164
+ * What a row is to this caller, or null when they may not see it.
165
+ * `{ scope, owner: {type, id}, organizationId, access }` (§3.4). A `global`
166
+ * row is never described: it is not the tenant's to see.
167
+ */
168
+ function describeRecord(record, ctx, kind) {
169
+ if (!record) return null;
170
+ const scope = scopeOf(record, ctx);
171
+ if (scope === null || scope === SCOPES.GLOBAL) return null;
172
+ if (!ctx.clauses[kind].some((clause) => matchesClause(record, clause))) {
173
+ return null;
174
+ }
175
+ return {
176
+ scope,
177
+ owner: { type: scope, id: idString(record.userId) },
178
+ organizationId:
179
+ scope === SCOPES.ORGANIZATION
180
+ ? idString(record.userId)
181
+ : idString(record.organizationId),
182
+ access: accessFor(scope, ctx),
183
+ };
184
+ }
185
+
186
+ /** `?owner=` values and the scope each one means. */
187
+ const LIST_OWNERS = Object.freeze({
188
+ me: SCOPES.INDIVIDUAL,
189
+ org: SCOPES.ORGANIZATION,
190
+ });
191
+
192
+ /** Whether a described row passes `?scope=` and `?owner=`. */
193
+ function matchesListFilters(description, { scope, owner } = {}) {
194
+ if (scope !== undefined && description.scope !== scope) return false;
195
+ if (owner !== undefined && description.owner.type !== LIST_OWNERS[owner]) {
196
+ return false;
197
+ }
198
+ return true;
199
+ }
200
+
201
+ module.exports = {
202
+ canManageOrganization,
203
+ accessToOwner,
204
+ accessContext,
205
+ unscopedRowsBelongHere,
206
+ visibilityClauses,
207
+ matchesClause,
208
+ scopeOf,
209
+ describeRecord,
210
+ LIST_OWNERS,
211
+ matchesListFilters,
212
+ };
@@ -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,59 @@
1
+ /**
2
+ * Authorizes an entity for a caller: decides who will own it and in which
3
+ * organization (ADR-056 §3.2, §3.6), then runs the module's authorization.
4
+ * Shared by the v1 `POST /api/authorize` and the v2 authorize flow, so the
5
+ * owner is decided in one place and never by `user.primary`.
6
+ */
7
+ class AuthorizeEntityForUser {
8
+ /**
9
+ * @param {Object} params
10
+ * @param {Object} params.scopePolicy - from createScopePolicy
11
+ * @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback
12
+ */
13
+ constructor({ scopePolicy, processAuthorizationCallback }) {
14
+ this.scopePolicy = scopePolicy;
15
+ this.processAuthorizationCallback = processAuthorizationCallback;
16
+ }
17
+
18
+ /**
19
+ * The scope, owner and organization a new entity of `entityType` gets for
20
+ * this caller. Throws a 400/403 when the request cannot have one.
21
+ *
22
+ * @param {{user: Object, entityType: string, scope?: string}} params
23
+ */
24
+ resolveOwnership({ user, entityType, scope }) {
25
+ return this.scopePolicy.resolveOwnership({
26
+ entityType,
27
+ requestScope: scope,
28
+ user,
29
+ });
30
+ }
31
+
32
+ /**
33
+ * @param {Object} params
34
+ * @param {Object} params.user - authenticated User
35
+ * @param {string} params.entityType
36
+ * @param {Object} params.data - what the module's authorization needs
37
+ * @param {string} [params.scope] - the scope the request asks for
38
+ * @param {Object} [params.ownership] - from `resolveOwnership`, when the
39
+ * caller needed it earlier (a multi-step flow does)
40
+ * @returns {Promise<{result: Object, ownership: Object}>} the callback's
41
+ * `{ credential_id, entity_id, type }` and the ownership it ran with
42
+ */
43
+ async execute({ user, entityType, data, scope, ownership }) {
44
+ const resolved = ownership ?? this.resolveOwnership({ user, entityType, scope });
45
+ const result = await this.processAuthorizationCallback.execute(
46
+ resolved.ownerId,
47
+ entityType,
48
+ data,
49
+ {
50
+ scope: resolved.scope,
51
+ organizationId: resolved.organizationId,
52
+ legacyOrgId: resolved.legacyOrgId,
53
+ }
54
+ );
55
+ return { result, ownership: resolved };
56
+ }
57
+ }
58
+
59
+ module.exports = { AuthorizeEntityForUser };
@@ -1,4 +1,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
@@ -1,5 +1,6 @@
1
1
  const Boom = require('@hapi/boom');
2
2
  const { ModuleConstants } = require('../ModuleConstants');
3
+ const { usedByOf } = require('../entity-type-registry');
3
4
 
4
5
  const AUTH_TYPE_BY_REQUESTER = {
5
6
  [ModuleConstants.authType.oauth2]: 'oauth2',
@@ -22,16 +23,16 @@ function getAuthStepCount(definition) {
22
23
  * definition: display metadata, auth type and step count. Never credentials
23
24
  * or env values.
24
25
  */
25
- function describeEntityType(definition) {
26
+ function describeEntityType(definition, typeName = definition.moduleName) {
26
27
  const stepCount = getAuthStepCount(definition);
27
28
  const display = definition.display || {};
28
29
  const description = {
29
- type: definition.moduleName,
30
+ type: typeName,
30
31
  name:
31
32
  callIfFunction(definition, 'getDisplayName') ??
32
33
  display.label ??
33
34
  display.name ??
34
- definition.moduleName,
35
+ typeName,
35
36
  authType:
36
37
  callIfFunction(definition, 'getAuthType') ??
37
38
  AUTH_TYPE_BY_REQUESTER[definition.API?.requesterType] ??
@@ -58,27 +59,74 @@ function findDefinition(moduleDefinitions, entityType) {
58
59
  return definition;
59
60
  }
60
61
 
61
- /** Lists the entity types (API modules) this app can authorize. */
62
+ /**
63
+ * With a scope policy the entry also says which scope a new connection gets
64
+ * and which a request may choose (ADR-056 §3.1), so a UI can offer the choice.
65
+ * `global` is never offered: a tenant request cannot create one.
66
+ */
67
+ const scopeOf = (scopePolicy, name, user) => {
68
+ if (!scopePolicy) return {};
69
+ const { scope, source, allow } = scopePolicy.resolveScope({
70
+ entityType: name,
71
+ user,
72
+ });
73
+ return {
74
+ scope: {
75
+ default: scope,
76
+ allow: allow.filter((value) => value !== 'global'),
77
+ source,
78
+ },
79
+ };
80
+ };
81
+
82
+ const describeEntry = (name, entry, scopePolicy, user) => ({
83
+ // The registry key is the name the API uses, whatever the definition spells.
84
+ ...describeEntityType(entry.definition, name),
85
+ usedBy: usedByOf(entry),
86
+ ...scopeOf(scopePolicy, name, user),
87
+ });
88
+
89
+ function requireRegistry(entityTypeRegistry) {
90
+ if (!(entityTypeRegistry instanceof Map)) {
91
+ throw new TypeError('entityTypeRegistry is required (see getEntityTypeRegistry)');
92
+ }
93
+ return entityTypeRegistry;
94
+ }
95
+
96
+ /**
97
+ * Lists the entity types this app can authorize: registry entries plus every
98
+ * module an integration binds (ADR-059). `usedBy` names the integrations that
99
+ * bind each type; it is empty for a registry-only type.
100
+ */
62
101
  class ListEntityTypes {
63
- constructor({ moduleDefinitions }) {
64
- this.moduleDefinitions = moduleDefinitions || [];
102
+ constructor({ entityTypeRegistry, scopePolicy }) {
103
+ this.registry = requireRegistry(entityTypeRegistry);
104
+ this.scopePolicy = scopePolicy;
65
105
  }
66
106
 
67
- async execute() {
68
- return this.moduleDefinitions
69
- .map(describeEntityType)
107
+ /** @param {{user?: Object}} [params] - the caller, for the level-6 scope default */
108
+ async execute({ user } = {}) {
109
+ return [...this.registry]
110
+ .map(([name, entry]) => describeEntry(name, entry, this.scopePolicy, user))
70
111
  .sort((a, b) => a.name.localeCompare(b.name));
71
112
  }
72
113
  }
73
114
 
74
- /** Describes one entity type; 404 when the app does not configure it. */
115
+ /** Describes one entity type, looked up by its registry key; 404 when absent. */
75
116
  class GetEntityType {
76
- constructor({ moduleDefinitions }) {
77
- this.moduleDefinitions = moduleDefinitions || [];
117
+ constructor({ entityTypeRegistry, scopePolicy }) {
118
+ this.registry = requireRegistry(entityTypeRegistry);
119
+ this.scopePolicy = scopePolicy;
78
120
  }
79
121
 
80
- async execute(entityType) {
81
- return describeEntityType(findDefinition(this.moduleDefinitions, entityType));
122
+ async execute(entityType, { user } = {}) {
123
+ const entry = this.registry.get(entityType);
124
+ if (!entry) {
125
+ throw Boom.notFound(`Entity type '${entityType}' not found`, {
126
+ code: 'ENTITY_TYPE_NOT_FOUND',
127
+ });
128
+ }
129
+ return describeEntry(entityType, entry, this.scopePolicy, user);
82
130
  }
83
131
  }
84
132
 
@@ -98,9 +98,24 @@ class ExecuteEntityProxyRequest {
98
98
  audit.requestBytes = validated.requestBytes;
99
99
  const path = normalizeProxyPath(validated.path);
100
100
 
101
+ // Access is `use` in the active organization (ADR-056 §3.5, §3.6): the
102
+ // owner's entity there, or the organization's. A `global` entity is
103
+ // never the tenant's.
101
104
  const { entity, definition, module } =
102
105
  await this.getEntityModuleForUser.execute(entityId, user);
103
106
  audit.module = definition.moduleName;
107
+ // Narrowest by default (§3.5): proxying through a shared credential
108
+ // lets any member act on the organization's account, so only an
109
+ // individual entity may be proxied until `managementApi.proxy.scopes`
110
+ // lets an app opt in to `organization`.
111
+ // TODO(F6): read proxy.scopes here instead of refusing outright.
112
+ if (this.getEntityModuleForUser.describe(entity, user)?.scope !== 'individual') {
113
+ throw new ProxyRequestError(
114
+ 'PROXY_NOT_ALLOWED',
115
+ 'The entity proxy only accepts individual entities; organization-level entities are not proxied',
116
+ { module: definition.moduleName, reason: 'scope' }
117
+ );
118
+ }
104
119
  const credentialRef = entity.credential;
105
120
  const credentialId =
106
121
  credentialRef && typeof credentialRef === 'object'
@@ -1,8 +1,11 @@
1
1
  const { Module } = require('../module');
2
2
  const { mapModuleClassToModuleDTO } = require('../utils/map-module-dto');
3
+ const { describeRecord, visibilityClauses } = require('../scope-visibility');
4
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
3
5
 
4
6
  class GetEntitiesForUser {
5
- constructor({ moduleRepository, moduleDefinitions }) {
7
+ constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
8
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
6
9
  this.moduleRepository = moduleRepository;
7
10
 
8
11
  this.definitionMap = new Map();
@@ -11,16 +14,24 @@ class GetEntitiesForUser {
11
14
  }
12
15
  }
13
16
 
14
- async execute(userId) {
15
- const entities = await this.moduleRepository.findEntitiesByUserId(
16
- userId
17
+ /**
18
+ * The entities a `User` may see in the active organization (ADR-056 §3.6).
19
+ * @param {Object} user - authenticated User
20
+ */
21
+ async execute(user) {
22
+ const ctx = this.scopePolicy.accessContext(user);
23
+ const found = await this.moduleRepository.findEntitiesByOwnership(
24
+ visibilityClauses(ctx, 'entity')
25
+ );
26
+ const entities = (found || []).filter((entity) =>
27
+ describeRecord(entity, ctx, 'entity')
17
28
  );
18
29
 
19
30
  return entities.map((entity) => {
20
31
  const definition = this.definitionMap.get(entity.moduleName);
21
32
 
22
33
  const moduleInstance = new Module({
23
- userId,
34
+ userId: entity.userId,
24
35
  definition: definition,
25
36
  entity: entity,
26
37
  });
@@ -1,8 +1,13 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { describeRecord } = require('../scope-visibility');
3
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
2
4
 
3
5
  /**
4
6
  * True when `user` may act on records owned by `ownerId`. Uses the User
5
7
  * domain object's rule (individual or organisation user model) when present.
8
+ * This is the owner-only rule: it ignores the organization a record belongs
9
+ * to, so it no longer decides access to an entity or an integration
10
+ * (modules/scope-visibility.js does). Credentials still use it.
6
11
  * @param {Object} user - authenticated User (or a plain { getId } double)
7
12
  * @param {string|number} ownerId
8
13
  */
@@ -30,8 +35,11 @@ class GetEntityModuleForUser {
30
35
  * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
31
36
  * @param {Array<Object>} params.moduleDefinitions
32
37
  * @param {Function} [params.createModule] - builds a Module; injectable for tests.
38
+ * @param {Object} [params.scopePolicy] - from createScopePolicy; with none the
39
+ * app configures nothing (ADR-056 §3.1).
33
40
  */
34
- constructor({ moduleRepository, moduleDefinitions, createModule }) {
41
+ constructor({ moduleRepository, moduleDefinitions, createModule, scopePolicy }) {
42
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
35
43
  this.moduleRepository = moduleRepository;
36
44
  this.moduleDefinitions = moduleDefinitions || [];
37
45
  this.createModule = createModule || defaultCreateModule;
@@ -61,7 +69,11 @@ class GetEntityModuleForUser {
61
69
  return { entity, definition, module };
62
70
  }
63
71
 
64
- /** The entity record, or a 404 when it is missing or not the caller's. */
72
+ /**
73
+ * The entity record, or a 404 when it is missing or the caller may not use
74
+ * it: not theirs, or paired with another organization (ADR-056 §3.6). A
75
+ * `global` entity is never the tenant's to load.
76
+ */
65
77
  async loadOwnedEntity(entityId, user) {
66
78
  let entity = null;
67
79
  try {
@@ -74,13 +86,21 @@ class GetEntityModuleForUser {
74
86
  throw error;
75
87
  }
76
88
  }
77
- if (!entity || !userOwns(user, entity.userId)) {
89
+ if (!entity || !this.describe(entity, user)) {
78
90
  throw Boom.notFound(`Entity ${entityId} not found`, {
79
91
  code: 'ENTITY_NOT_FOUND',
80
92
  });
81
93
  }
82
94
  return entity;
83
95
  }
96
+
97
+ /**
98
+ * `{ scope, owner, organizationId, access }` of an entity to this caller,
99
+ * or null when they may not see it (§3.4).
100
+ */
101
+ describe(entity, user) {
102
+ return describeRecord(entity, this.scopePolicy.accessContext(user), 'entity');
103
+ }
84
104
  }
85
105
 
86
106
  module.exports = { GetEntityModuleForUser, userOwns };
@@ -1,4 +1,5 @@
1
1
  const { Module } = require('../module');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
2
3
 
3
4
  class GetEntityOptionsById {
4
5
  /**
@@ -6,9 +7,11 @@ class GetEntityOptionsById {
6
7
  * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
7
8
  * @param {} params.moduleDefinitions
8
9
  */
9
- constructor({ moduleRepository, moduleDefinitions }) {
10
+ constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
10
11
  this.moduleRepository = moduleRepository;
11
12
  this.moduleDefinitions = moduleDefinitions;
13
+ // Who may load an entity follows its scope (ADR-056 §3.6).
14
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
12
15
  }
13
16
 
14
17
  /**
@@ -35,7 +38,7 @@ class GetEntityOptionsById {
35
38
 
36
39
  // Validate entity ownership
37
40
  const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
38
- ? userIdOrUser.ownsUserId(entity.userId)
41
+ ? this.scopePolicy.canUseEntity(userIdOrUser, entity)
39
42
  : entity.userId?.toString() === userId?.toString();
40
43
 
41
44
  if (!isOwned) {