@friggframework/core 2.0.0--canary.682.d8626c4.0 → 2.0.0--canary.685.a13fdab.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 (35) hide show
  1. package/CLAUDE.md +23 -0
  2. package/README.md +1 -1
  3. package/credential/use-cases/credential-operations-for-user.js +110 -25
  4. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  5. package/handlers/app-definition-loader.js +9 -0
  6. package/index.js +4 -0
  7. package/integrations/integration-router.js +7 -5
  8. package/integrations/use-cases/integration-operations-for-user.js +16 -6
  9. package/management-api/management-api-config.js +70 -4
  10. package/management-api/openapi/components.js +1 -0
  11. package/management-api/openapi/v2-operations.js +6 -3
  12. package/management-api/v2/dependencies.js +2 -0
  13. package/management-api/v2/dto.js +3 -0
  14. package/management-api/v2/handlers/credentials.js +6 -2
  15. package/modules/repositories/entity-not-found.js +18 -0
  16. package/modules/repositories/module-repository-documentdb.js +3 -2
  17. package/modules/repositories/module-repository-mongo.js +2 -1
  18. package/modules/repositories/module-repository-postgres.js +2 -1
  19. package/modules/repositories/module-repository.js +2 -1
  20. package/modules/scope-config.js +38 -1
  21. package/modules/scope-policy.js +35 -3
  22. package/modules/scope-resolution.js +11 -0
  23. package/modules/scope-visibility.js +90 -8
  24. package/modules/use-cases/authorize-entity-for-user.js +4 -2
  25. package/modules/use-cases/entity-operations-for-user.js +9 -4
  26. package/modules/use-cases/execute-entity-proxy-request.js +50 -18
  27. package/modules/use-cases/get-entity-module-for-user.js +9 -2
  28. package/modules/use-cases/manage-access.js +21 -0
  29. package/modules/use-cases/multi-step-authorization.js +4 -1
  30. package/package.json +5 -5
  31. package/types/modules/index.d.ts +42 -0
  32. package/types/user/index.d.ts +17 -0
  33. package/user/use-cases/authenticate-user.js +7 -1
  34. package/user/use-cases/get-user-from-adopter-jwt.js +17 -7
  35. package/user/user.js +39 -3
package/CLAUDE.md CHANGED
@@ -502,6 +502,29 @@ The single source of the max receive count is
502
502
  devtools integration builder uses it for the queue's `RedrivePolicy` and sets
503
503
  it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
504
504
 
505
+ ### Management API v2 notes (`/management-api`)
506
+
507
+ - Handlers in `management-api/v2/handlers/` call use cases only; dependencies are wired in
508
+ `management-api/v2/dependencies.js`. Errors are `{ error: { code, message, details? } }`.
509
+ - `appDefinition.managementApi` is resolved by `resolveManagementApiConfig`, which `loadAppDefinition`
510
+ also calls so a bad value stops the app at load. Numeric proxy limits fall back to their defaults;
511
+ `proxy.scopes` (ADR-056 §3.5, default `['individual']`) never does: an unknown scope, `'global'`, a
512
+ non-list, an empty list or a repeated scope throws `ManagementApiConfigError`
513
+ (`MANAGEMENT_API_CONFIG_INVALID`).
514
+ - **Entity proxy** (`ExecuteEntityProxyRequest`): visibility in the active organization comes from
515
+ `GetEntityModuleForUser` (404), then `assertProxyable` reads the same `{ scope, access }` description
516
+ the v2 lists return: `grantsUse(access)` (`use` or `manage`, from `modules/scope-visibility.js`) or 403
517
+ `PROXY_NOT_ALLOWED` `reason: 'access'`, and the scope must be in `proxy.scopes` or 403 `reason: 'scope'`.
518
+ Do not add a second ownership rule for the proxy; change the description instead.
519
+ - **Not visible is 404, and so is missing.** Module repositories throw `entityNotFoundError(id)`
520
+ (`modules/repositories/entity-not-found.js`) for a missing row and use cases match it with
521
+ `isEntityNotFound`. A 500 for a missing id next to a 404 for another tenant's id tells a caller which
522
+ ids exist. A test double that resolves `findEntityById` to `null` hides this: no adapter does that.
523
+ - **Tenant isolation on real PostgreSQL**: `management-api/v2/tenant-isolation.postgres.test.js`, run with
524
+ `FRIGG_TEST_POSTGRES_URL=<a database it may wipe>`. It applies the migrations, truncates every table
525
+ before each test, and drives real adopter-JWT auth through the real router and Prisma repositories.
526
+ Without the variable it is skipped (and says so), not passed. See `docs/guides/entity-scopes.md`.
527
+
505
528
  ### 8. Error Handling (`/errors`)
506
529
 
507
530
  **Purpose**: Standardized error types with proper HTTP semantics.
package/README.md CHANGED
@@ -393,7 +393,7 @@ value that is not a three-part JWT is not offered to the verifier at all; it fal
393
393
  | `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
394
394
  | `userIdClaim` | `sub` | App user id (required in the token). |
395
395
  | `orgIdClaim` | `org_id` | App organization id (optional). |
396
- | `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). |
396
+ | `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). For permissions the token's role wins; a token with none uses the role stored on the person's membership of the active organization, and is a member when there is none: it cannot create or change organization-level entities or integrations. `user.orgAdminRoles` (default `['ADMIN']`) names the roles that can. Frigg bearer tokens are not gated unless `user.orgAdminGateBearerTokens: true`. |
397
397
 
398
398
  Rules worth knowing:
399
399
 
@@ -1,10 +1,28 @@
1
1
  const Boom = require('@hapi/boom');
2
- const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
2
+ const {
3
+ createScopePolicy,
4
+ describeRecord,
5
+ visibilityClauses,
6
+ matchesListFilters,
7
+ SCOPES,
8
+ } = require('../../modules/entity-scope');
9
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
3
10
 
4
11
  /**
5
- * Management API v2 credential operations. A credential has no module name
6
- * of its own; its `type` and the entities that use it come from the
7
- * caller's entities. A missing or foreign credential is a 404.
12
+ * Management API v2 credential operations (ADR-056 §3.4, §3.6). A credential
13
+ * has no module name and no organization of its own:
14
+ *
15
+ * - its `type` and `entityIds` come from the entities that use it that the
16
+ * caller may see, so the same person's account connected in two
17
+ * organizations (one credential, two entities) lists in each organization
18
+ * with that organization's entity only;
19
+ * - who it belongs to is its OWNER (`credential.userId`), never the entities:
20
+ * a credential an individual entity and an organization entity both point at
21
+ * (possible only through legacy data) is described by whoever owns the
22
+ * credential, so the answer cannot change with the order the entities load;
23
+ * - a credential whose entities are all out of sight (another organization's)
24
+ * is a 404, and one with no entities at all (its entity was deleted) is
25
+ * visible to its owner only, as it was before scoping.
8
26
  */
9
27
 
10
28
  const credentialIdOf = (entity) => {
@@ -37,31 +55,82 @@ function withEntityUsage(credentials, entities) {
37
55
  });
38
56
  }
39
57
 
40
- /** The caller's credentials with their type and entity usage. */
58
+ /**
59
+ * `{scope, owner, organizationId, access}` of a credential for this caller, or
60
+ * null when its owner is neither the caller nor the active organization. Built
61
+ * by `describeRecord` over the row a connection by this owner has: the
62
+ * organization of a visible entity, else the one a new entity would get.
63
+ */
64
+ function credentialTenancy(credential, visibleEntities, ctx) {
65
+ const entityOrg = visibleEntities.find(
66
+ (entity) => entity.organizationId !== undefined && entity.organizationId !== null
67
+ )?.organizationId;
68
+ const organizationId =
69
+ entityOrg ?? (ctx.individualAcross === 'all' ? null : ctx.activeOrgId);
70
+ return describeRecord({ userId: credential.userId, organizationId }, ctx, 'entity');
71
+ }
72
+
73
+ /** The entities of `entities` the caller may see, each with its `tenancy`. */
74
+ function visibleOf(entities, ctx) {
75
+ const visible = [];
76
+ for (const entity of entities || []) {
77
+ const tenancy = describeRecord(entity, ctx, 'entity');
78
+ if (tenancy) visible.push({ ...entity, tenancy });
79
+ }
80
+ return visible;
81
+ }
82
+
83
+ /**
84
+ * The caller's credentials with their type, the entities of them they may see
85
+ * and `tenancy`. Credentials with no entity at all are listed too, for the
86
+ * person and the active organization that own them.
87
+ */
41
88
  class ListCredentialsForUser {
42
- constructor({ credentialRepository, moduleRepository }) {
89
+ constructor({ credentialRepository, moduleRepository, scopePolicy }) {
43
90
  this.credentialRepository = credentialRepository;
44
91
  this.moduleRepository = moduleRepository;
92
+ this.scopePolicy = scopePolicy || createScopePolicy();
45
93
  }
46
94
 
47
- async execute(user) {
48
- const userId = user.getId();
49
- const [credentials, entities] = await Promise.all([
50
- this.credentialRepository.findCredentialsByUserId(userId),
51
- this.moduleRepository.findEntitiesByUserId(userId),
95
+ /** @param {{scope?: string, owner?: string}} [filters] */
96
+ async execute(user, filters = {}) {
97
+ const ctx = this.scopePolicy.accessContext(user);
98
+ const owners = [...new Set([ctx.callerId, ctx.activeOrgId].filter(Boolean))];
99
+ const [found, perOwner, owned] = await Promise.all([
100
+ this.moduleRepository.findEntitiesByOwnership(visibilityClauses(ctx, 'entity')),
101
+ Promise.all(owners.map((id) => this.credentialRepository.findCredentialsByUserId(id))),
102
+ // Every entity of those owners, in any organization: only to tell a credential
103
+ // with no entity at all from one used elsewhere.
104
+ Promise.all(owners.map((id) => this.moduleRepository.findEntitiesByUserId(id))),
52
105
  ]);
53
- return withEntityUsage(credentials || [], entities);
106
+ const visible = visibleOf(found, ctx);
107
+ const usedAnywhere = new Set(owned.flat().map(credentialIdOf).filter(Boolean));
108
+ const listed = [];
109
+ for (const credential of perOwner.flat().filter(Boolean)) {
110
+ const id = String(credential.id);
111
+ const referencing = visible.filter((entity) => credentialIdOf(entity) === id);
112
+ if (referencing.length === 0 && usedAnywhere.has(id)) continue;
113
+ const tenancy = credentialTenancy(credential, referencing, ctx);
114
+ if (!tenancy || !matchesListFilters(tenancy, filters)) continue;
115
+ listed.push({ ...withEntityUsage([credential], referencing)[0], tenancy });
116
+ }
117
+ return listed;
54
118
  }
55
119
  }
56
120
 
57
- /** One credential the caller owns, with its type and entity usage. */
121
+ /** One credential the caller may see: see the header. 404 otherwise. */
58
122
  class GetCredentialForUserV2 {
59
- constructor({ credentialRepository, moduleRepository }) {
123
+ constructor({ credentialRepository, moduleRepository, scopePolicy }) {
60
124
  this.credentialRepository = credentialRepository;
61
125
  this.moduleRepository = moduleRepository;
126
+ this.scopePolicy = scopePolicy || createScopePolicy();
62
127
  }
63
128
 
64
129
  async execute(credentialId, user) {
130
+ const notFound = () =>
131
+ Boom.notFound(`Credential ${credentialId} not found`, {
132
+ code: 'CREDENTIAL_NOT_FOUND',
133
+ });
65
134
  let credential = null;
66
135
  try {
67
136
  credential = await this.credentialRepository.findCredentialById(
@@ -72,22 +141,29 @@ class GetCredentialForUserV2 {
72
141
  throw error;
73
142
  }
74
143
  }
75
- if (!credential || !userOwns(user, credential.userId)) {
76
- throw Boom.notFound(`Credential ${credentialId} not found`, {
77
- code: 'CREDENTIAL_NOT_FOUND',
78
- });
79
- }
80
- const entities = await this.moduleRepository.findEntitiesByUserId(
81
- credential.userId
82
- );
83
- return withEntityUsage([credential], entities)[0];
144
+ if (!credential) throw notFound();
145
+ const ctx = this.scopePolicy.accessContext(user);
146
+
147
+ const referencing =
148
+ (await this.moduleRepository.findEntities({ credentialId: credential.id })) || [];
149
+ const visible = visibleOf(referencing, ctx);
150
+ if (referencing.length > 0 && visible.length === 0) throw notFound();
151
+ const tenancy = credentialTenancy(credential, visible, ctx);
152
+ if (!tenancy) throw notFound();
153
+ return { ...withEntityUsage([credential], visible)[0], tenancy };
84
154
  }
85
155
  }
86
156
 
87
157
  /**
88
- * Deletes a credential the caller owns. The entities that used it stay, with
89
- * their credential unset, so they show as needing re-authorization rather
158
+ * Deletes a credential the caller may manage. The entities that used it stay,
159
+ * with their credential unset, so they show as needing re-authorization rather
90
160
  * than disappearing from integrations.
161
+ *
162
+ * Refuses with 409 `CREDENTIAL_SHARED` while an entity the caller cannot see
163
+ * still uses it: one credential row serves the same provider account in every
164
+ * organization, and deleting it from one would break the other. The message
165
+ * says nothing about the other connections (not even how many): remove the
166
+ * entities that use it, and it goes with the last one.
91
167
  */
92
168
  class DeleteCredentialForUser {
93
169
  constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
@@ -101,6 +177,15 @@ class DeleteCredentialForUser {
101
177
  credentialId,
102
178
  user
103
179
  );
180
+ assertCanManage(credential.tenancy.access, `credential ${credential.id}`);
181
+ const usedBy =
182
+ (await this.moduleRepository.findEntities({ credentialId: credential.id })) || [];
183
+ if (usedBy.length > credential.entityIds.length) {
184
+ throw Boom.conflict(
185
+ `Credential ${credential.id} is still in use by other connections and cannot be deleted from here`,
186
+ { code: 'CREDENTIAL_SHARED' }
187
+ );
188
+ }
104
189
  for (const entityId of credential.entityIds) {
105
190
  await this.moduleRepository.unsetCredential(entityId);
106
191
  }
@@ -1,4 +1,5 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
2
3
 
3
4
  /**
4
5
  * Re-authorization of a credential the caller owns (Management API v2). It
@@ -14,7 +15,13 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
14
15
  { code: 'CREDENTIAL_TYPE_UNKNOWN' }
15
16
  );
16
17
  }
17
- return { entityType: credential.type, credentialId: String(credential.id) };
18
+ return {
19
+ entityType: credential.type,
20
+ credentialId: String(credential.id),
21
+ // A re-authorization keeps the scope the connection already has.
22
+ fixedScope: credential.tenancy?.scope,
23
+ access: credential.tenancy?.access,
24
+ };
18
25
  }
19
26
 
20
27
  /** GET /api/v2/credentials/:credentialId/reauthorize */
@@ -25,14 +32,12 @@ class GetCredentialReauthorization {
25
32
  }
26
33
 
27
34
  async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
28
- const binding = await credentialEntityType(
29
- this.getCredentialForUser,
30
- credentialId,
31
- user
32
- );
35
+ const { entityType, credentialId: boundCredentialId } =
36
+ await credentialEntityType(this.getCredentialForUser, credentialId, user);
33
37
  return this.getAuthorizationStep.execute({
34
38
  user,
35
- ...binding,
39
+ entityType,
40
+ credentialId: boundCredentialId,
36
41
  step,
37
42
  sessionId,
38
43
  state,
@@ -48,11 +53,13 @@ class ReauthorizeCredentialForUser {
48
53
  }
49
54
 
50
55
  async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
51
- const binding = await credentialEntityType(
56
+ const { access, ...binding } = await credentialEntityType(
52
57
  this.getCredentialForUser,
53
58
  credentialId,
54
59
  user
55
60
  );
61
+ // A credential an organization owns: the organization's admins only.
62
+ assertCanManage(access, `credential ${credentialId}`);
56
63
  return this.submitAuthorizationStep.execute({
57
64
  user,
58
65
  ...binding,
@@ -12,6 +12,9 @@ const {
12
12
  getEntityTypeDefinitions,
13
13
  } = require('../modules/entity-type-registry');
14
14
  const { resolveOwnershipConfig } = require('../modules/entity-scope');
15
+ const {
16
+ resolveManagementApiConfig,
17
+ } = require('../management-api/management-api-config');
15
18
 
16
19
  // Handlers without a database never load the encryption registry, so the
17
20
  // credential leaf keys are registered for redaction here too.
@@ -39,6 +42,7 @@ function registerCredentialLogKeys(appDefinition, moduleDefinitions) {
39
42
  * @throws {Error} Throws error if index.js file cannot be found in the backend directory.
40
43
  * @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig.
41
44
  * @throws {OwnershipConfigError} Throws if an ownership or scope setting is invalid (ADR-056 §3.1).
45
+ * @throws {ManagementApiConfigError} Throws if `managementApi.proxy.scopes` is invalid (ADR-056 §3.5).
42
46
  * @example
43
47
  * const { integrations, userConfig, telemetry } = loadAppDefinition();
44
48
  * console.log(`Found ${integrations.length} integrations`);
@@ -84,6 +88,11 @@ function loadAppDefinition() {
84
88
  // individual entity stops the app at load.
85
89
  const ownership = resolveOwnershipConfig(appDefinition, entityTypeRegistry);
86
90
 
91
+ // And for the Management API: `managementApi.proxy.scopes` decides whose
92
+ // credentials the entity proxy may act through (ADR-056 §3.5), so an
93
+ // unknown scope stops the app here rather than being read as a default.
94
+ resolveManagementApiConfig(managementApi);
95
+
87
96
  registerCredentialLogKeys(appDefinition, moduleDefinitions);
88
97
 
89
98
  // Degrade consistently: an invalid telemetry block must never take down a
package/index.js CHANGED
@@ -113,6 +113,9 @@ const {
113
113
  listBindingScopes,
114
114
  OwnershipConfigError,
115
115
  } = require('./modules/index');
116
+ const {
117
+ ManagementApiConfigError,
118
+ } = require('./management-api/management-api-config');
116
119
  const application = require('./application');
117
120
  const utils = require('./utils');
118
121
 
@@ -229,6 +232,7 @@ module.exports = {
229
232
  resolveScope,
230
233
  listBindingScopes,
231
234
  OwnershipConfigError,
235
+ ManagementApiConfigError,
232
236
  // queues
233
237
  QueuerUtil,
234
238
 
@@ -291,9 +291,11 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
291
291
  } = useCases;
292
292
 
293
293
  // The owner of an integration the caller may reach (404 otherwise), which is
294
- // what the id-and-owner use cases below expect.
295
- const ownerOf = async (integrationId, user) =>
296
- (await getOwnedIntegrationRecord.execute(integrationId, user)).userId;
294
+ // what the id-and-owner use cases below expect. `manage: true` is for
295
+ // changing or deleting: an organization's integration needs an admin (403).
296
+ const ownerOf = async (integrationId, user, options) =>
297
+ (await getOwnedIntegrationRecord.execute(integrationId, user, options))
298
+ .userId;
297
299
 
298
300
  router.route('/api/integrations').get(
299
301
  catchAsyncError(async (req, res) => {
@@ -345,7 +347,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
345
347
 
346
348
  const integration = await updateIntegration.execute(
347
349
  req.params.integrationId,
348
- await ownerOf(req.params.integrationId, user),
350
+ await ownerOf(req.params.integrationId, user, { manage: true }),
349
351
  params.config
350
352
  );
351
353
  res.json(integration);
@@ -358,7 +360,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
358
360
  const params = checkRequiredParams(req.params, ['integrationId']);
359
361
  await deleteIntegrationForUser.execute(
360
362
  params.integrationId,
361
- await ownerOf(params.integrationId, user)
363
+ await ownerOf(params.integrationId, user, { manage: true })
362
364
  );
363
365
  res.status(204).json({});
364
366
  })
@@ -1,6 +1,7 @@
1
1
  const Boom = require('@hapi/boom');
2
2
  const { describeRecord } = require('../../modules/scope-visibility');
3
3
  const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
4
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
4
5
  const { isIntegrationNotFound } = require('../repositories/integration-not-found');
5
6
 
6
7
  /**
@@ -32,7 +33,14 @@ class GetOwnedIntegrationRecord {
32
33
  return describeRecord(record, this.scopePolicy.accessContext(user), 'integration');
33
34
  }
34
35
 
35
- async execute(integrationId, user) {
36
+ /**
37
+ * @param {string} integrationId
38
+ * @param {Object} user - authenticated User
39
+ * @param {{manage?: boolean}} [options] - `manage: true` for changing or
40
+ * deleting: a person may use an organization's integration but only an
41
+ * admin may change it (403 ORG_ADMIN_REQUIRED)
42
+ */
43
+ async execute(integrationId, user, { manage = false } = {}) {
36
44
  let record = null;
37
45
  try {
38
46
  record = await this.integrationRepository.findIntegrationById(
@@ -47,9 +55,9 @@ class GetOwnedIntegrationRecord {
47
55
  }
48
56
  throw error;
49
57
  }
50
- if (!record || !this.describe(record, user)) {
51
- throw notFound(integrationId);
52
- }
58
+ const tenancy = record && this.describe(record, user);
59
+ if (!tenancy) throw notFound(integrationId);
60
+ if (manage) assertCanManage(tenancy.access, `integration ${integrationId}`);
53
61
  return record;
54
62
  }
55
63
  }
@@ -138,7 +146,8 @@ class UpdateIntegrationForUser {
138
146
  }
139
147
  const record = await this.getOwnedIntegrationRecord.execute(
140
148
  integrationId,
141
- user
149
+ user,
150
+ { manage: true }
142
151
  );
143
152
  return this.updateIntegration.execute(record.id, record.userId, config);
144
153
  }
@@ -154,7 +163,8 @@ class RemoveIntegrationForUser {
154
163
  async execute(integrationId, user) {
155
164
  const record = await this.getOwnedIntegrationRecord.execute(
156
165
  integrationId,
157
- user
166
+ user,
167
+ { manage: true }
158
168
  );
159
169
  await this.deleteIntegrationForUser.execute(record.id, record.userId);
160
170
  }
@@ -9,6 +9,7 @@
9
9
  * timeoutMs: 25000, // whole upstream call, lower only
10
10
  * maxRequestBytes: 1048576,
11
11
  * maxResponseBytes: 5242880, // lower only
12
+ * scopes: ['individual'], // entity scopes the proxy accepts (ADR-056 §3.5)
12
13
  * modules: { // per-module allow-list override
13
14
  * hubspot: { allow: 'module' },
14
15
  * erp: { allow: [{ method: 'GET', path: '/v1/customers/**' }] },
@@ -17,10 +18,36 @@
17
18
  * }
18
19
  * ```
19
20
  *
20
- * TODO(F6): `proxy.scopes` (ADR-056 §3.5) is not read yet; see
21
- * ExecuteEntityProxyRequest.
21
+ * Limits that are out of range fall back to their defaults. `proxy.scopes` is a
22
+ * security setting, so it does not fall back: a value that is not a list of
23
+ * known tenant scopes throws ManagementApiConfigError, and loadAppDefinition
24
+ * calls this so the app stops at load.
22
25
  */
23
26
 
27
+ const { BaseError } = require('../errors/base-error');
28
+ const { SCOPES } = require('../modules/scope-resolution');
29
+
30
+ /** Raised for an invalid `appDefinition.managementApi` setting. */
31
+ class ManagementApiConfigError extends BaseError {
32
+ constructor(message, options) {
33
+ super(message, options);
34
+ this.code = 'MANAGEMENT_API_CONFIG_INVALID';
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The scopes `managementApi.proxy.scopes` may list. `global` is not one of
40
+ * them: a global entity is the app owner's and is never a tenant's to proxy.
41
+ */
42
+ const PROXY_SCOPE_VALUES = Object.freeze([SCOPES.INDIVIDUAL, SCOPES.ORGANIZATION]);
43
+
44
+ /**
45
+ * Narrowest by default (ADR-056 §3.5): proxying through a shared credential
46
+ * lets any member act on the organization's account, so an app opts in to
47
+ * `organization` explicitly.
48
+ */
49
+ const DEFAULT_PROXY_SCOPES = Object.freeze([SCOPES.INDIVIDUAL]);
50
+
24
51
  // ADR-052 §7. API Gateway HTTP APIs time out at 30s and Lambda responses cap
25
52
  // at 6MB, so the timeout and response defaults are also ceilings: an app may
26
53
  // lower them, not raise them.
@@ -47,9 +74,41 @@ function positiveOr(value, fallback, ceiling) {
47
74
  return isPositiveNumber(value) ? Math.min(value, ceiling) : fallback;
48
75
  }
49
76
 
77
+ const proxyScopesError = (message) =>
78
+ new ManagementApiConfigError(`managementApi.proxy.scopes: ${message}`);
79
+
80
+ /**
81
+ * `managementApi.proxy.scopes`: absent means the default; anything else must be
82
+ * a non-empty list of PROXY_SCOPE_VALUES, each once.
83
+ * @returns {ReadonlyArray<string>}
84
+ * @throws {ManagementApiConfigError}
85
+ */
86
+ function resolveProxyScopes(scopes) {
87
+ if (scopes === undefined) return DEFAULT_PROXY_SCOPES;
88
+ const allowed = PROXY_SCOPE_VALUES.join(', ');
89
+ if (!Array.isArray(scopes) || scopes.length === 0) {
90
+ throw proxyScopesError(`must be a non-empty list drawn from ${allowed}`);
91
+ }
92
+ for (const scope of scopes) {
93
+ if (scope === SCOPES.GLOBAL) {
94
+ throw proxyScopesError(
95
+ "'global' is not allowed: global entities belong to the app owner and are never proxied for a tenant"
96
+ );
97
+ }
98
+ if (!PROXY_SCOPE_VALUES.includes(scope)) {
99
+ throw proxyScopesError(`unknown scope ${JSON.stringify(scope)}; use ${allowed}`);
100
+ }
101
+ }
102
+ if (new Set(scopes).size !== scopes.length) {
103
+ throw proxyScopesError('lists a scope more than once');
104
+ }
105
+ return Object.freeze([...scopes]);
106
+ }
107
+
50
108
  /**
51
109
  * @param {Object} [managementApi] - `appDefinition.managementApi`
52
- * @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, modules: Object } }}
110
+ * @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, scopes: ReadonlyArray<string>, modules: Object } }}
111
+ * @throws {ManagementApiConfigError} for an invalid `proxy.scopes`
53
112
  */
54
113
  function resolveManagementApiConfig(managementApi) {
55
114
  const config =
@@ -74,6 +133,7 @@ function resolveManagementApiConfig(managementApi) {
74
133
  proxy.maxResponseBytes,
75
134
  DEFAULT_PROXY_LIMITS.maxResponseBytes
76
135
  ),
136
+ scopes: resolveProxyScopes(proxy.scopes),
77
137
  modules:
78
138
  proxy.modules && typeof proxy.modules === 'object'
79
139
  ? proxy.modules
@@ -82,4 +142,10 @@ function resolveManagementApiConfig(managementApi) {
82
142
  };
83
143
  }
84
144
 
85
- module.exports = { resolveManagementApiConfig, DEFAULT_PROXY_LIMITS };
145
+ module.exports = {
146
+ resolveManagementApiConfig,
147
+ DEFAULT_PROXY_LIMITS,
148
+ DEFAULT_PROXY_SCOPES,
149
+ PROXY_SCOPE_VALUES,
150
+ ManagementApiConfigError,
151
+ };
@@ -206,6 +206,7 @@ const SCHEMAS = {
206
206
  authIsValid: nullable({ type: 'boolean' }),
207
207
  entityIds: { type: 'array', items: { type: 'string' } },
208
208
  entityCount: { type: 'integer', minimum: 0 },
209
+ ...tenancyProperties,
209
210
  createdAt: nullable({ type: 'string', format: 'date-time' }),
210
211
  updatedAt: nullable({ type: 'string', format: 'date-time' }),
211
212
  data: {
@@ -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. 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. 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.",
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', ref('ProxyError')),
175
- 404: ok('Entity not found', ref('Error')),
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')),
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,6 +184,9 @@ 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,
187
190
  responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
188
191
  },
189
192
  getCredential: {
@@ -124,6 +124,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
124
124
  const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
125
125
  credentialRepository,
126
126
  moduleRepository,
127
+ scopePolicy,
127
128
  });
128
129
  const getAuthorizationRequirements = new GetAuthorizationRequirements({
129
130
  moduleDefinitions,
@@ -246,6 +247,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
246
247
  listCredentialsForUser: new credentialOps.ListCredentialsForUser({
247
248
  credentialRepository,
248
249
  moduleRepository,
250
+ scopePolicy,
249
251
  }),
250
252
  getCredentialForUser,
251
253
  deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
@@ -75,6 +75,8 @@ 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',
78
80
  ]);
79
81
 
80
82
  /**
@@ -126,6 +128,7 @@ function maskCredential(credential) {
126
128
  entityCount: entityIds.length,
127
129
  createdAt: toIso(credential.createdAt),
128
130
  updatedAt: toIso(credential.updatedAt),
131
+ ...tenancyFields(credential.tenancy),
129
132
  data,
130
133
  };
131
134
  }
@@ -1,4 +1,5 @@
1
1
  const { maskCredential } = require('../dto');
2
+ const { parseListFilters } = require('../request');
2
3
 
3
4
  /** Management API v2 credential handlers, keyed by operationId. */
4
5
  function createCredentialHandlers(useCases) {
@@ -6,8 +7,11 @@ function createCredentialHandlers(useCases) {
6
7
  useCases;
7
8
 
8
9
  return {
9
- async listCredentials(_req, res, user) {
10
- const credentials = await listCredentialsForUser.execute(user);
10
+ async listCredentials(req, res, user) {
11
+ const credentials = await listCredentialsForUser.execute(
12
+ user,
13
+ parseListFilters(req.query)
14
+ );
11
15
  res.json({ credentials: credentials.map(maskCredential) });
12
16
  },
13
17
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The error `findEntityById` throws for an id with no row, and the one
3
+ * predicate that recognises it (as integration-not-found.js does for
4
+ * integrations). The adapters build the error here and use cases match it
5
+ * here, so a missing entity can be told from any other failure without
6
+ * widening a regex.
7
+ *
8
+ * Why it matters: a use case that answers 404 for another tenant's entity must
9
+ * answer the same 404 for an id that does not exist. Otherwise the status code
10
+ * says which ids exist (ADR-052 §3, ADR-056 §3.6).
11
+ */
12
+ const MESSAGE = /^Entity .+ not found$/;
13
+
14
+ const entityNotFoundError = (id) => new Error(`Entity ${id} not found`);
15
+
16
+ const isEntityNotFound = (error) => MESSAGE.test(error?.message || '');
17
+
18
+ module.exports = { entityNotFoundError, isEntityNotFound };