@friggframework/core 2.0.0--canary.685.022c3c3.0 → 2.0.0--canary.683.8a84ab1.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 (38) hide show
  1. package/CLAUDE.md +0 -23
  2. package/README.md +1 -1
  3. package/credential/use-cases/credential-operations-for-user.js +33 -110
  4. package/credential/use-cases/reauthorize-credential-for-user.js +13 -10
  5. package/handlers/app-definition-loader.js +0 -9
  6. package/index.js +3 -4
  7. package/integrations/index.js +5 -0
  8. package/integrations/integration-router.js +26 -28
  9. package/integrations/repositories/integration-repository-documentdb.js +2 -1
  10. package/integrations/utils/map-integration-dto.js +44 -1
  11. package/management-api/management-api-config.js +4 -70
  12. package/management-api/openapi/components.js +0 -1
  13. package/management-api/openapi/v2-operations.js +3 -6
  14. package/management-api/v2/dependencies.js +2 -2
  15. package/management-api/v2/dto.js +0 -3
  16. package/management-api/v2/handlers/credentials.js +2 -6
  17. package/modules/repositories/module-repository-documentdb.js +8 -6
  18. package/modules/repositories/module-repository-mongo.js +1 -2
  19. package/modules/repositories/module-repository-postgres.js +1 -2
  20. package/modules/repositories/module-repository.js +1 -2
  21. package/modules/scope-config.js +16 -1
  22. package/modules/scope-policy.js +3 -17
  23. package/modules/scope-visibility.js +37 -18
  24. package/modules/use-cases/authorize-entity-for-user.js +2 -4
  25. package/modules/use-cases/create-entity-from-credential.js +113 -0
  26. package/modules/use-cases/execute-entity-proxy-request.js +18 -50
  27. package/modules/use-cases/get-entity-module-for-user.js +2 -9
  28. package/modules/use-cases/get-entity-options-by-id.js +4 -1
  29. package/modules/use-cases/get-module.js +4 -1
  30. package/modules/use-cases/multi-step-authorization.js +1 -4
  31. package/modules/use-cases/refresh-entity-options.js +4 -1
  32. package/modules/use-cases/test-module-auth.js +4 -1
  33. package/package.json +5 -5
  34. package/types/modules/index.d.ts +9 -41
  35. package/types/user/index.d.ts +14 -4
  36. package/user/use-cases/get-user-from-adopter-jwt.js +17 -7
  37. package/user/user.js +17 -4
  38. package/modules/repositories/entity-not-found.js +0 -18
@@ -165,14 +165,14 @@ const V2_OPERATIONS = {
165
165
  proxyEntityRequest: {
166
166
  summary: "Call the entity's API through Frigg (beta)",
167
167
  description:
168
- "ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. ADR-056 §3.5: the caller needs use access to the entity in the active organization (an entity they cannot see there is 404), and the entity's scope must be in managementApi.proxy.scopes (default individual only; 403 PROXY_NOT_ALLOWED with details.reason 'scope', or 'access' for view-only access). The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
168
+ "ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
169
169
  requestBody: body(ref('ProxyRequest')),
170
170
  responses: {
171
171
  200: ok('Upstream answer (the status mirrors the upstream status)', ref('ProxyResponse')),
172
172
  400: ok('INVALID_PROXY_REQUEST', ref('ProxyError')),
173
173
  401: ok('INVALID_CREDENTIALS or unauthenticated', ref('ProxyError')),
174
- 403: ok("PROXY_NOT_ALLOWED: not in the allow-list, or details.reason 'scope' / 'access'", ref('ProxyError')),
175
- 404: ok('Entity not found, or not visible in the active organization', ref('Error')),
174
+ 403: ok('PROXY_NOT_ALLOWED', ref('ProxyError')),
175
+ 404: ok('Entity not found', ref('Error')),
176
176
  413: ok('PAYLOAD_TOO_LARGE', ref('ProxyError')),
177
177
  415: ok('UNSUPPORTED_MEDIA_TYPE', ref('ProxyError')),
178
178
  429: ok('RATE_LIMITED (Retry-After passed through)', ref('ProxyError')),
@@ -184,9 +184,6 @@ const V2_OPERATIONS = {
184
184
  // Credentials
185
185
  listCredentials: {
186
186
  summary: "List the caller's credentials (masked)",
187
- description:
188
- 'A credential is listed through the entities that use it that the caller may see, so each organization lists only its own connections; one no entity uses is listed to its owner. Each carries `scope`, `owner`, `organizationId` and `access`, taken from who owns the credential.',
189
- parameters: listFilters,
190
187
  responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
191
188
  },
192
189
  getCredential: {
@@ -124,7 +124,6 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
124
124
  const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
125
125
  credentialRepository,
126
126
  moduleRepository,
127
- scopePolicy,
128
127
  });
129
128
  const getAuthorizationRequirements = new GetAuthorizationRequirements({
130
129
  moduleDefinitions,
@@ -247,13 +246,13 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
247
246
  listCredentialsForUser: new credentialOps.ListCredentialsForUser({
248
247
  credentialRepository,
249
248
  moduleRepository,
250
- scopePolicy,
251
249
  }),
252
250
  getCredentialForUser,
253
251
  deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
254
252
  credentialRepository,
255
253
  moduleRepository,
256
254
  getCredentialForUser,
255
+ scopePolicy,
257
256
  }),
258
257
  getCredentialReauthorization: new GetCredentialReauthorization({
259
258
  getCredentialForUser,
@@ -262,6 +261,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
262
261
  reauthorizeCredentialForUser: new ReauthorizeCredentialForUser({
263
262
  getCredentialForUser,
264
263
  submitAuthorizationStep,
264
+ scopePolicy,
265
265
  }),
266
266
 
267
267
  // Authorization (multi-step)
@@ -75,8 +75,6 @@ const CREDENTIAL_METADATA = new Set([
75
75
  'type',
76
76
  'entityIds',
77
77
  'status',
78
- // Added by the credential use cases (ADR-056 §3.4); never provider data.
79
- 'tenancy',
80
78
  ]);
81
79
 
82
80
  /**
@@ -128,7 +126,6 @@ function maskCredential(credential) {
128
126
  entityCount: entityIds.length,
129
127
  createdAt: toIso(credential.createdAt),
130
128
  updatedAt: toIso(credential.updatedAt),
131
- ...tenancyFields(credential.tenancy),
132
129
  data,
133
130
  };
134
131
  }
@@ -1,5 +1,4 @@
1
1
  const { maskCredential } = require('../dto');
2
- const { parseListFilters } = require('../request');
3
2
 
4
3
  /** Management API v2 credential handlers, keyed by operationId. */
5
4
  function createCredentialHandlers(useCases) {
@@ -7,11 +6,8 @@ function createCredentialHandlers(useCases) {
7
6
  useCases;
8
7
 
9
8
  return {
10
- async listCredentials(req, res, user) {
11
- const credentials = await listCredentialsForUser.execute(
12
- user,
13
- parseListFilters(req.query)
14
- );
9
+ async listCredentials(_req, res, user) {
10
+ const credentials = await listCredentialsForUser.execute(user);
15
11
  res.json({ credentials: credentials.map(maskCredential) });
16
12
  },
17
13
 
@@ -3,13 +3,13 @@ const {
3
3
  toObjectId,
4
4
  fromObjectId,
5
5
  findMany,
6
+ findManyDrained,
6
7
  findOne,
7
8
  insertOne,
8
9
  updateOne,
9
10
  deleteOne,
10
11
  } = require('../../database/documentdb-utils');
11
12
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
12
- const { entityNotFoundError } = require('./entity-not-found');
13
13
  const { DocumentDBEncryptionService } = require('../../database/documentdb-encryption-service');
14
14
 
15
15
  /**
@@ -34,11 +34,11 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
34
34
  async findEntityById(entityId) {
35
35
  const objectId = toObjectId(entityId);
36
36
  if (!objectId) {
37
- throw entityNotFoundError(entityId);
37
+ throw new Error(`Entity ${entityId} not found`);
38
38
  }
39
39
  const doc = await findOne(this.prisma, 'Entity', { _id: objectId });
40
40
  if (!doc) {
41
- throw entityNotFoundError(entityId);
41
+ throw new Error(`Entity ${entityId} not found`);
42
42
  }
43
43
  const credential = await this._fetchCredential(doc.credentialId);
44
44
  return this._mapEntity(doc, credential);
@@ -88,7 +88,8 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
88
88
  */
89
89
  async findEntitiesByOwnership(clauses) {
90
90
  if (!Array.isArray(clauses) || clauses.length === 0) return [];
91
- const docs = await findMany(this.prisma, 'Entity', {
91
+ // Drained: a list can hold more than the first cursor batch (~101).
92
+ const docs = await findManyDrained(this.prisma, 'Entity', {
92
93
  $or: clauses.map(({ userId, organizationId }) => ({
93
94
  userId: toObjectId(userId),
94
95
  organizationId:
@@ -278,8 +279,9 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
278
279
  const objectIds = ids.map(id => toObjectId(id)).filter(Boolean);
279
280
  if (objectIds.length === 0) return new Map();
280
281
 
281
- // Use raw findMany to bypass Prisma encryption extension
282
- const rawCredentials = await findMany(this.prisma, 'Credential', {
282
+ // Raw read to bypass the Prisma encryption extension. Drained:
283
+ // more ids than the first cursor batch must all come back.
284
+ const rawCredentials = await findManyDrained(this.prisma, 'Credential', {
283
285
  _id: { $in: objectIds }
284
286
  });
285
287
 
@@ -1,7 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { organizationWhere } = require('../../database/organization-where');
3
3
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
4
- const { entityNotFoundError } = require('./entity-not-found');
5
4
 
6
5
  /**
7
6
  * MongoDB Module Repository Adapter
@@ -112,7 +111,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
112
111
  });
113
112
 
114
113
  if (!entity) {
115
- throw entityNotFoundError(entityId);
114
+ throw new Error(`Entity ${entityId} not found`);
116
115
  }
117
116
 
118
117
  const credential = await this._fetchCredential(entity.credentialId);
@@ -1,6 +1,5 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
3
- const { entityNotFoundError } = require('./entity-not-found');
4
3
 
5
4
  /**
6
5
  * PostgreSQL Module Repository Adapter
@@ -145,7 +144,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
145
144
  });
146
145
 
147
146
  if (!entity) {
148
- throw entityNotFoundError(entityId);
147
+ throw new Error(`Entity ${entityId} not found`);
149
148
  }
150
149
 
151
150
  const credential = await this._fetchCredential(entity.credentialId);
@@ -1,6 +1,5 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
3
- const { entityNotFoundError } = require('./entity-not-found');
4
3
 
5
4
  /**
6
5
  * Prisma-based Module Repository
@@ -38,7 +37,7 @@ class ModuleRepository extends ModuleRepositoryInterface {
38
37
  });
39
38
 
40
39
  if (!entity) {
41
- throw entityNotFoundError(entityId);
40
+ throw new Error(`Entity ${entityId} not found`);
42
41
  }
43
42
 
44
43
  return {
@@ -96,6 +96,20 @@ function readOrgAdminRoles(appDefinition) {
96
96
  return [...raw];
97
97
  }
98
98
 
99
+ /**
100
+ * `user.orgAdminGateBearerTokens`: judge Frigg bearer-token callers by role
101
+ * like adopter-token callers. Default false: they keep their pre-roles
102
+ * behaviour and may manage the organization they act in.
103
+ */
104
+ function readGateBearerTokens(appDefinition) {
105
+ const raw = appDefinition?.user?.orgAdminGateBearerTokens;
106
+ if (raw === undefined || raw === null) return false;
107
+ if (typeof raw !== 'boolean') {
108
+ throw configError('appDefinition.user.orgAdminGateBearerTokens', 'must be a boolean');
109
+ }
110
+ return raw;
111
+ }
112
+
99
113
  /**
100
114
  * Every binding of every integration with its resolved scope and where it came
101
115
  * from, parsing each integration's settings once. Throws OwnershipConfigError
@@ -139,7 +153,7 @@ function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
139
153
  * Validates every ownership setting in one pass and returns the app-level
140
154
  * config the runtime needs.
141
155
  *
142
- * @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[]}}
156
+ * @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[], gateBearerTokens: boolean}}
143
157
  * @throws {OwnershipConfigError}
144
158
  */
145
159
  function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
@@ -165,6 +179,7 @@ function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
165
179
  scope: app.scope,
166
180
  individualAcross: app.individualAcross,
167
181
  orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
182
+ gateBearerTokens: readGateBearerTokens(appDefinition),
168
183
  });
169
184
  }
170
185
 
@@ -20,7 +20,7 @@ const {
20
20
 
21
21
  /**
22
22
  * @param {Object} [params]
23
- * @param {{scope: *, individualAcross: string, orgAdminRoles?: string[]}} [params.ownership] - the raw
23
+ * @param {{scope: *, individualAcross: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [params.ownership] - the raw
24
24
  * `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
25
25
  * `{ default, allow }` parses to itself)
26
26
  * @param {Map} [params.entityTypeRegistry]
@@ -39,9 +39,10 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
39
39
  }
40
40
 
41
41
  const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
42
+ const gateBearerTokens = ownership?.gateBearerTokens === true;
42
43
 
43
44
  const contextFor = (user) =>
44
- accessContext(user, { individualAcross, orgAdminRoles });
45
+ accessContext(user, { individualAcross, orgAdminRoles, gateBearerTokens });
45
46
 
46
47
  /** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
47
48
  const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
@@ -69,20 +70,6 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
69
70
  };
70
71
  };
71
72
 
72
- /**
73
- * The owner and organization for a scope that is already decided: a
74
- * re-authorization keeps the scope its connection has, whatever the config
75
- * says now. The admin gate applies as it does on create.
76
- */
77
- const ownershipForScope = ({ scope, user }) => {
78
- const ctx = contextFor(user);
79
- return {
80
- ...resolveOwnerForScope(scope, ctx),
81
- legacyOrgId: ctx.legacyOrgId,
82
- source: 'fixed',
83
- };
84
- };
85
-
86
73
  /** Whether `user` may load this stored entity for use (same clauses as the list). */
87
74
  const canUseEntity = (user, entity) =>
88
75
  describeRecord(entity, contextFor(user), 'entity') !== null;
@@ -100,7 +87,6 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
100
87
  accessContext: contextFor,
101
88
  resolveScope: resolveEntityScope,
102
89
  resolveOwnership,
103
- ownershipForScope,
104
90
  canUseEntity,
105
91
  });
106
92
  }
@@ -69,17 +69,34 @@ function buildClauses(base, kind) {
69
69
 
70
70
  /**
71
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.
72
+ * delete), ADR-056 §3.6:
76
73
  *
77
- * @param {{authSource: (string|null), orgRole: (string|null)}} who
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
78
86
  * @param {string[]} adminRoles - `user.orgAdminRoles`, compared case-insensitively
87
+ * @param {{gateBearerTokens?: boolean}} [options]
79
88
  */
80
- function canManageOrganization({ authSource, orgRole }, adminRoles = DEFAULT_ORG_ADMIN_ROLES) {
89
+ function canManageOrganization(
90
+ { authSource, orgRole, membershipRole },
91
+ adminRoles = DEFAULT_ORG_ADMIN_ROLES,
92
+ { gateBearerTokens = false } = {}
93
+ ) {
81
94
  if (authSource === AUTH_SOURCES.SHARED_SECRET) return true;
82
- const role = String(isPresent(orgRole) ? orgRole : 'MEMBER').toUpperCase();
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();
83
100
  return adminRoles.some((admin) => String(admin).toUpperCase() === role);
84
101
  }
85
102
 
@@ -90,11 +107,15 @@ function canManageOrganization({ authSource, orgRole }, adminRoles = DEFAULT_ORG
90
107
  * follow from them (computed once).
91
108
  *
92
109
  * @param {Object} user - the authenticated `User` (or a `{ getId }` double)
93
- * @param {{individualAcross?: string, orgAdminRoles?: string[]}} [options]
110
+ * @param {{individualAcross?: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [options]
94
111
  */
95
112
  function accessContext(
96
113
  user,
97
- { individualAcross = 'organization', orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES } = {}
114
+ {
115
+ individualAcross = 'organization',
116
+ orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES,
117
+ gateBearerTokens = false,
118
+ } = {}
98
119
  ) {
99
120
  const hasUserModel = typeof user?.getIndividualUser === 'function';
100
121
  const individual = hasUserModel ? user.getIndividualUser() : null;
@@ -111,10 +132,16 @@ function accessContext(
111
132
  authSource:
112
133
  typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
113
134
  orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
135
+ membershipRole:
136
+ typeof user?.getMembershipRole === 'function'
137
+ ? user.getMembershipRole()
138
+ : null,
114
139
  };
115
140
  return {
116
141
  ...base,
117
- canManageOrg: canManageOrganization(who, orgAdminRoles),
142
+ canManageOrg: canManageOrganization(who, orgAdminRoles, {
143
+ gateBearerTokens,
144
+ }),
118
145
  clauses: {
119
146
  entity: buildClauses(base, 'entity'),
120
147
  integration: buildClauses(base, 'integration'),
@@ -148,13 +175,6 @@ const accessFor = (scope, ctx) => {
148
175
  return ACCESS.USE;
149
176
  };
150
177
 
151
- /**
152
- * Whether an access level lets the caller use a record: bind it, proxy through
153
- * it (§3.4). `manage` includes `use`; `view` (metadata only) does not, and
154
- * neither does anything unrecognised.
155
- */
156
- const grantsUse = (access) => access === ACCESS.USE || access === ACCESS.MANAGE;
157
-
158
178
  /**
159
179
  * What the caller may do with a record owned by `ownerId` that carries no
160
180
  * organization of its own (a credential): manage the person's own, manage the
@@ -214,7 +234,6 @@ module.exports = {
214
234
  matchesClause,
215
235
  scopeOf,
216
236
  describeRecord,
217
- grantsUse,
218
237
  LIST_OWNERS,
219
238
  matchesListFilters,
220
239
  };
@@ -19,11 +19,9 @@ class AuthorizeEntityForUser {
19
19
  * The scope, owner and organization a new entity of `entityType` gets for
20
20
  * this caller. Throws a 400/403 when the request cannot have one.
21
21
  *
22
- * @param {{user: Object, entityType: string, scope?: string, fixedScope?: string}} params
23
- * `fixedScope`: a re-authorization keeps the scope its connection has
22
+ * @param {{user: Object, entityType: string, scope?: string}} params
24
23
  */
25
- resolveOwnership({ user, entityType, scope, fixedScope }) {
26
- if (fixedScope) return this.scopePolicy.ownershipForScope({ scope: fixedScope, user });
24
+ resolveOwnership({ user, entityType, scope }) {
27
25
  return this.scopePolicy.resolveOwnership({
28
26
  entityType,
29
27
  requestScope: scope,
@@ -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 };
@@ -2,8 +2,6 @@ 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');
7
5
  const { normalizeProxyPath, matchAllowRule } = require('../proxy/proxy-path');
8
6
  const {
9
7
  validateProxyRequest,
@@ -57,11 +55,9 @@ const authHeaderNamesOf = (api) =>
57
55
  * through its module with the stored credential, inside the limits the app
58
56
  * and the module set.
59
57
  *
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,
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,
65
61
  * allowed or denied, writes one audit record and one usage count. Bodies,
66
62
  * headers, query values and the raw path are never logged.
67
63
  */
@@ -102,14 +98,24 @@ class ExecuteEntityProxyRequest {
102
98
  audit.requestBytes = validated.requestBytes;
103
99
  const path = normalizeProxyPath(validated.path);
104
100
 
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
+ // 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.
108
104
  const { entity, definition, module } =
109
105
  await this.getEntityModuleForUser.execute(entityId, user);
110
106
  audit.module = definition.moduleName;
111
- this.assertProxyable(entityId, entity, definition, user);
112
-
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
+ }
113
119
  const credentialRef = entity.credential;
114
120
  const credentialId =
115
121
  credentialRef && typeof credentialRef === 'object'
@@ -211,44 +217,6 @@ class ExecuteEntityProxyRequest {
211
217
  }
212
218
  }
213
219
 
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
-
252
220
  /** A module may pin its proxy base URL (string, or a function of the api). */
253
221
  resolveBaseUrl(definition, api) {
254
222
  const declared = definition.proxy?.baseUrl;
@@ -1,7 +1,6 @@
1
1
  const Boom = require('@hapi/boom');
2
2
  const { describeRecord } = require('../scope-visibility');
3
3
  const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
4
- const { isEntityNotFound } = require('../repositories/entity-not-found');
5
4
 
6
5
  /**
7
6
  * True when `user` may act on records owned by `ownerId`. Uses the User
@@ -80,14 +79,8 @@ class GetEntityModuleForUser {
80
79
  try {
81
80
  entity = await this.moduleRepository.findEntityById(entityId);
82
81
  } catch (error) {
83
- // Every adapter throws for a missing row, and a malformed id (e.g.
84
- // not an integer on Postgres) is "not found" too. Both must be the
85
- // 404 another tenant's entity gets, or the status says which ids
86
- // exist. Anything else is a real failure.
87
- if (
88
- isEntityNotFound(error) ||
89
- /Invalid ID|ObjectId|Malformed/i.test(error?.message || '')
90
- ) {
82
+ // A malformed id (e.g. not an integer on Postgres) is "not found".
83
+ if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
91
84
  entity = null;
92
85
  } else {
93
86
  throw error;
@@ -60,8 +60,11 @@ class GetEntityOptionsById {
60
60
  );
61
61
  }
62
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.
63
66
  const module = new Module({
64
- userId,
67
+ userId: entity.userId ?? userId,
65
68
  entity,
66
69
  definition: moduleDefinition,
67
70
  });