@friggframework/core 2.0.0--canary.683.f45da33.0 → 2.0.0--canary.684.e113124.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.
@@ -1,12 +1,28 @@
1
1
  const Boom = require('@hapi/boom');
2
- const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
3
- const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
2
+ const {
3
+ createScopePolicy,
4
+ describeRecord,
5
+ visibilityClauses,
6
+ matchesListFilters,
7
+ SCOPES,
8
+ } = require('../../modules/entity-scope');
4
9
  const { assertCanManage } = require('../../modules/use-cases/manage-access');
5
10
 
6
11
  /**
7
- * Management API v2 credential operations. A credential has no module name
8
- * of its own; its `type` and the entities that use it come from the
9
- * 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.
10
26
  */
11
27
 
12
28
  const credentialIdOf = (entity) => {
@@ -39,31 +55,82 @@ function withEntityUsage(credentials, entities) {
39
55
  });
40
56
  }
41
57
 
42
- /** 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
+ */
43
88
  class ListCredentialsForUser {
44
- constructor({ credentialRepository, moduleRepository }) {
89
+ constructor({ credentialRepository, moduleRepository, scopePolicy }) {
45
90
  this.credentialRepository = credentialRepository;
46
91
  this.moduleRepository = moduleRepository;
92
+ this.scopePolicy = scopePolicy || createScopePolicy();
47
93
  }
48
94
 
49
- async execute(user) {
50
- const userId = user.getId();
51
- const [credentials, entities] = await Promise.all([
52
- this.credentialRepository.findCredentialsByUserId(userId),
53
- 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))),
54
105
  ]);
55
- 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;
56
118
  }
57
119
  }
58
120
 
59
- /** One credential the caller owns, with its type and entity usage. */
121
+ /** One credential the caller may see: see the header. 404 otherwise. */
60
122
  class GetCredentialForUserV2 {
61
- constructor({ credentialRepository, moduleRepository }) {
123
+ constructor({ credentialRepository, moduleRepository, scopePolicy }) {
62
124
  this.credentialRepository = credentialRepository;
63
125
  this.moduleRepository = moduleRepository;
126
+ this.scopePolicy = scopePolicy || createScopePolicy();
64
127
  }
65
128
 
66
129
  async execute(credentialId, user) {
130
+ const notFound = () =>
131
+ Boom.notFound(`Credential ${credentialId} not found`, {
132
+ code: 'CREDENTIAL_NOT_FOUND',
133
+ });
67
134
  let credential = null;
68
135
  try {
69
136
  credential = await this.credentialRepository.findCredentialById(
@@ -74,26 +141,32 @@ class GetCredentialForUserV2 {
74
141
  throw error;
75
142
  }
76
143
  }
77
- if (!credential || !userOwns(user, credential.userId)) {
78
- throw Boom.notFound(`Credential ${credentialId} not found`, {
79
- code: 'CREDENTIAL_NOT_FOUND',
80
- });
81
- }
82
- const entities = await this.moduleRepository.findEntitiesByUserId(
83
- credential.userId
84
- );
85
- 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 };
86
154
  }
87
155
  }
88
156
 
89
157
  /**
90
- * Deletes a credential the caller owns. The entities that used it stay, with
91
- * 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
92
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.
93
167
  */
94
168
  class DeleteCredentialForUser {
95
- constructor({ credentialRepository, moduleRepository, getCredentialForUser, scopePolicy }) {
96
- this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
169
+ constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
97
170
  this.credentialRepository = credentialRepository;
98
171
  this.moduleRepository = moduleRepository;
99
172
  this.getCredentialForUser = getCredentialForUser;
@@ -104,11 +177,15 @@ class DeleteCredentialForUser {
104
177
  credentialId,
105
178
  user
106
179
  );
107
- // A credential an organization owns: the organization's admins only.
108
- // (null: not the person's or the active organization's, which the
109
- // owner check above already decided.)
110
- const access = this.scopePolicy.accessToOwnedBy(user, credential.userId);
111
- if (access !== null) assertCanManage(access, `credential ${credential.id}`);
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
+ }
112
189
  for (const entityId of credential.entityIds) {
113
190
  await this.moduleRepository.unsetCredential(entityId);
114
191
  }
@@ -1,5 +1,4 @@
1
1
  const Boom = require('@hapi/boom');
2
- const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
3
2
  const { assertCanManage } = require('../../modules/use-cases/manage-access');
4
3
 
5
4
  /**
@@ -19,7 +18,9 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
19
18
  return {
20
19
  entityType: credential.type,
21
20
  credentialId: String(credential.id),
22
- ownerId: credential.userId,
21
+ // A re-authorization keeps the scope the connection already has.
22
+ fixedScope: credential.tenancy?.scope,
23
+ access: credential.tenancy?.access,
23
24
  };
24
25
  }
25
26
 
@@ -31,14 +32,12 @@ class GetCredentialReauthorization {
31
32
  }
32
33
 
33
34
  async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
34
- const { ownerId, ...binding } = await credentialEntityType(
35
- this.getCredentialForUser,
36
- credentialId,
37
- user
38
- );
35
+ const { entityType, credentialId: boundCredentialId } =
36
+ await credentialEntityType(this.getCredentialForUser, credentialId, user);
39
37
  return this.getAuthorizationStep.execute({
40
38
  user,
41
- ...binding,
39
+ entityType,
40
+ credentialId: boundCredentialId,
42
41
  step,
43
42
  sessionId,
44
43
  state,
@@ -48,21 +47,19 @@ class GetCredentialReauthorization {
48
47
 
49
48
  /** POST /api/v2/credentials/:credentialId/reauthorize */
50
49
  class ReauthorizeCredentialForUser {
51
- constructor({ getCredentialForUser, submitAuthorizationStep, scopePolicy }) {
52
- this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
50
+ constructor({ getCredentialForUser, submitAuthorizationStep }) {
53
51
  this.getCredentialForUser = getCredentialForUser;
54
52
  this.submitAuthorizationStep = submitAuthorizationStep;
55
53
  }
56
54
 
57
55
  async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
58
- const { ownerId, ...binding } = await credentialEntityType(
56
+ const { access, ...binding } = await credentialEntityType(
59
57
  this.getCredentialForUser,
60
58
  credentialId,
61
59
  user
62
60
  );
63
61
  // A credential an organization owns: the organization's admins only.
64
- const access = this.scopePolicy.accessToOwnedBy(user, ownerId);
65
- if (access !== null) assertCanManage(access, `credential ${credentialId}`);
62
+ assertCanManage(access, `credential ${credentialId}`);
66
63
  return this.submitAuthorizationStep.execute({
67
64
  user,
68
65
  ...binding,
@@ -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: {
@@ -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,13 +247,13 @@ 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({
252
254
  credentialRepository,
253
255
  moduleRepository,
254
256
  getCredentialForUser,
255
- scopePolicy,
256
257
  }),
257
258
  getCredentialReauthorization: new GetCredentialReauthorization({
258
259
  getCredentialForUser,
@@ -261,7 +262,6 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
261
262
  reauthorizeCredentialForUser: new ReauthorizeCredentialForUser({
262
263
  getCredentialForUser,
263
264
  submitAuthorizationStep,
264
- scopePolicy,
265
265
  }),
266
266
 
267
267
  // Authorization (multi-step)
@@ -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
 
@@ -69,6 +69,20 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
69
69
  };
70
70
  };
71
71
 
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
+
72
86
  /** Whether `user` may load this stored entity for use (same clauses as the list). */
73
87
  const canUseEntity = (user, entity) =>
74
88
  describeRecord(entity, contextFor(user), 'entity') !== null;
@@ -86,6 +100,7 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
86
100
  accessContext: contextFor,
87
101
  resolveScope: resolveEntityScope,
88
102
  resolveOwnership,
103
+ ownershipForScope,
89
104
  canUseEntity,
90
105
  });
91
106
  }
@@ -19,9 +19,11 @@ 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}} params
22
+ * @param {{user: Object, entityType: string, scope?: string, fixedScope?: string}} params
23
+ * `fixedScope`: a re-authorization keeps the scope its connection has
23
24
  */
24
- resolveOwnership({ user, entityType, scope }) {
25
+ resolveOwnership({ user, entityType, scope, fixedScope }) {
26
+ if (fixedScope) return this.scopePolicy.ownershipForScope({ scope: fixedScope, user });
25
27
  return this.scopePolicy.resolveOwnership({
26
28
  entityType,
27
29
  requestScope: scope,
@@ -166,8 +166,10 @@ class SubmitAuthorizationStep {
166
166
  * @param {Object} params
167
167
  * @param {string} [params.scope] - the scope the caller asks for, from the
168
168
  * deciding level's allow list (ADR-056 §3.1). Never `global`.
169
+ * @param {string} [params.fixedScope] - re-authorization keeps the scope the
170
+ * credential already has, whatever the config says now.
169
171
  */
170
- async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope }) {
172
+ async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope, fixedScope }) {
171
173
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
172
174
  throw Boom.badRequest('data must be an object', {
173
175
  code: 'VALIDATION_ERROR',
@@ -183,6 +185,7 @@ class SubmitAuthorizationStep {
183
185
  user,
184
186
  entityType,
185
187
  scope,
188
+ fixedScope,
186
189
  });
187
190
 
188
191
  if (!isMultiStep(definition)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.683.f45da33.0",
4
+ "version": "2.0.0--canary.684.e113124.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -48,9 +48,9 @@
48
48
  }
49
49
  },
50
50
  "devDependencies": {
51
- "@friggframework/eslint-config": "2.0.0--canary.683.f45da33.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.683.f45da33.0",
53
- "@friggframework/test": "2.0.0--canary.683.f45da33.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.684.e113124.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.684.e113124.0",
53
+ "@friggframework/test": "2.0.0--canary.684.e113124.0",
54
54
  "@prisma/client": "^6.19.3",
55
55
  "@types/lodash": "4.17.15",
56
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -90,5 +90,5 @@
90
90
  "publishConfig": {
91
91
  "access": "public"
92
92
  },
93
- "gitHead": "f45da337e62262988666a166b3d721020dae9161"
93
+ "gitHead": "e1131240a890bf30cfee08ba3786458c9e2d1135"
94
94
  }