@friggframework/core 2.0.0--canary.684.bfb48ef.0 → 2.0.0--canary.683.edb4f0b.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,28 +1,12 @@
1
1
  const Boom = require('@hapi/boom');
2
- const {
3
- createScopePolicy,
4
- describeRecord,
5
- visibilityClauses,
6
- matchesListFilters,
7
- SCOPES,
8
- } = require('../../modules/entity-scope');
2
+ const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
3
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
9
4
  const { assertCanManage } = require('../../modules/use-cases/manage-access');
10
5
 
11
6
  /**
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.
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.
26
10
  */
27
11
 
28
12
  const credentialIdOf = (entity) => {
@@ -55,82 +39,31 @@ function withEntityUsage(credentials, entities) {
55
39
  });
56
40
  }
57
41
 
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
- */
42
+ /** The caller's credentials with their type and entity usage. */
88
43
  class ListCredentialsForUser {
89
- constructor({ credentialRepository, moduleRepository, scopePolicy }) {
44
+ constructor({ credentialRepository, moduleRepository }) {
90
45
  this.credentialRepository = credentialRepository;
91
46
  this.moduleRepository = moduleRepository;
92
- this.scopePolicy = scopePolicy || createScopePolicy();
93
47
  }
94
48
 
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))),
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),
105
54
  ]);
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;
55
+ return withEntityUsage(credentials || [], entities);
118
56
  }
119
57
  }
120
58
 
121
- /** One credential the caller may see: see the header. 404 otherwise. */
59
+ /** One credential the caller owns, with its type and entity usage. */
122
60
  class GetCredentialForUserV2 {
123
- constructor({ credentialRepository, moduleRepository, scopePolicy }) {
61
+ constructor({ credentialRepository, moduleRepository }) {
124
62
  this.credentialRepository = credentialRepository;
125
63
  this.moduleRepository = moduleRepository;
126
- this.scopePolicy = scopePolicy || createScopePolicy();
127
64
  }
128
65
 
129
66
  async execute(credentialId, user) {
130
- const notFound = () =>
131
- Boom.notFound(`Credential ${credentialId} not found`, {
132
- code: 'CREDENTIAL_NOT_FOUND',
133
- });
134
67
  let credential = null;
135
68
  try {
136
69
  credential = await this.credentialRepository.findCredentialById(
@@ -141,32 +74,26 @@ class GetCredentialForUserV2 {
141
74
  throw error;
142
75
  }
143
76
  }
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 };
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];
154
86
  }
155
87
  }
156
88
 
157
89
  /**
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
+ * 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
160
92
  * 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.
167
93
  */
168
94
  class DeleteCredentialForUser {
169
- constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
95
+ constructor({ credentialRepository, moduleRepository, getCredentialForUser, scopePolicy }) {
96
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
170
97
  this.credentialRepository = credentialRepository;
171
98
  this.moduleRepository = moduleRepository;
172
99
  this.getCredentialForUser = getCredentialForUser;
@@ -177,15 +104,11 @@ class DeleteCredentialForUser {
177
104
  credentialId,
178
105
  user
179
106
  );
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
- }
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}`);
189
112
  for (const entityId of credential.entityIds) {
190
113
  await this.moduleRepository.unsetCredential(entityId);
191
114
  }
@@ -1,4 +1,5 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
2
3
  const { assertCanManage } = require('../../modules/use-cases/manage-access');
3
4
 
4
5
  /**
@@ -18,9 +19,7 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
18
19
  return {
19
20
  entityType: credential.type,
20
21
  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,
22
+ ownerId: credential.userId,
24
23
  };
25
24
  }
26
25
 
@@ -32,12 +31,14 @@ class GetCredentialReauthorization {
32
31
  }
33
32
 
34
33
  async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
35
- const { entityType, credentialId: boundCredentialId } =
36
- await credentialEntityType(this.getCredentialForUser, credentialId, user);
34
+ const { ownerId, ...binding } = await credentialEntityType(
35
+ this.getCredentialForUser,
36
+ credentialId,
37
+ user
38
+ );
37
39
  return this.getAuthorizationStep.execute({
38
40
  user,
39
- entityType,
40
- credentialId: boundCredentialId,
41
+ ...binding,
41
42
  step,
42
43
  sessionId,
43
44
  state,
@@ -47,19 +48,21 @@ class GetCredentialReauthorization {
47
48
 
48
49
  /** POST /api/v2/credentials/:credentialId/reauthorize */
49
50
  class ReauthorizeCredentialForUser {
50
- constructor({ getCredentialForUser, submitAuthorizationStep }) {
51
+ constructor({ getCredentialForUser, submitAuthorizationStep, scopePolicy }) {
52
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
51
53
  this.getCredentialForUser = getCredentialForUser;
52
54
  this.submitAuthorizationStep = submitAuthorizationStep;
53
55
  }
54
56
 
55
57
  async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
56
- const { access, ...binding } = await credentialEntityType(
58
+ const { ownerId, ...binding } = await credentialEntityType(
57
59
  this.getCredentialForUser,
58
60
  credentialId,
59
61
  user
60
62
  );
61
63
  // A credential an organization owns: the organization's admins only.
62
- assertCanManage(access, `credential ${credentialId}`);
64
+ const access = this.scopePolicy.accessToOwnedBy(user, ownerId);
65
+ if (access !== null) assertCanManage(access, `credential ${credentialId}`);
63
66
  return this.submitAuthorizationStep.execute({
64
67
  user,
65
68
  ...binding,
@@ -206,7 +206,6 @@ 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,
210
209
  createdAt: nullable({ type: 'string', format: 'date-time' }),
211
210
  updatedAt: nullable({ type: 'string', format: 'date-time' }),
212
211
  data: {
@@ -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
 
@@ -69,20 +69,6 @@ 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
-
86
72
  /** Whether `user` may load this stored entity for use (same clauses as the list). */
87
73
  const canUseEntity = (user, entity) =>
88
74
  describeRecord(entity, contextFor(user), 'entity') !== null;
@@ -100,7 +86,6 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
100
86
  accessContext: contextFor,
101
87
  resolveScope: resolveEntityScope,
102
88
  resolveOwnership,
103
- ownershipForScope,
104
89
  canUseEntity,
105
90
  });
106
91
  }
@@ -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,
@@ -166,10 +166,8 @@ 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.
171
169
  */
172
- async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope, fixedScope }) {
170
+ async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope }) {
173
171
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
174
172
  throw Boom.badRequest('data must be an object', {
175
173
  code: 'VALIDATION_ERROR',
@@ -185,7 +183,6 @@ class SubmitAuthorizationStep {
185
183
  user,
186
184
  entityType,
187
185
  scope,
188
- fixedScope,
189
186
  });
190
187
 
191
188
  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.684.bfb48ef.0",
4
+ "version": "2.0.0--canary.683.edb4f0b.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.684.bfb48ef.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.684.bfb48ef.0",
53
- "@friggframework/test": "2.0.0--canary.684.bfb48ef.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.683.edb4f0b.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.683.edb4f0b.0",
53
+ "@friggframework/test": "2.0.0--canary.683.edb4f0b.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": "bfb48ef7325e5052da0628e130ee6a77807d544e"
93
+ "gitHead": "edb4f0b45af2a0a70fc5c7dbe3324d9a8e088d70"
94
94
  }