@friggframework/core 2.0.0--canary.682.cbca9e2.0 → 2.0.0--canary.683.f45da33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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). A token with no role is a member for permissions too: it cannot create or change organization-level entities or integrations; `user.orgAdminRoles` (default `['ADMIN']`) names the roles that can. |
397
397
 
398
398
  Rules worth knowing:
399
399
 
@@ -1,5 +1,7 @@
1
1
  const Boom = require('@hapi/boom');
2
2
  const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
3
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
4
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
3
5
 
4
6
  /**
5
7
  * Management API v2 credential operations. A credential has no module name
@@ -90,7 +92,8 @@ class GetCredentialForUserV2 {
90
92
  * than disappearing from integrations.
91
93
  */
92
94
  class DeleteCredentialForUser {
93
- constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
95
+ constructor({ credentialRepository, moduleRepository, getCredentialForUser, scopePolicy }) {
96
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
94
97
  this.credentialRepository = credentialRepository;
95
98
  this.moduleRepository = moduleRepository;
96
99
  this.getCredentialForUser = getCredentialForUser;
@@ -101,6 +104,11 @@ class DeleteCredentialForUser {
101
104
  credentialId,
102
105
  user
103
106
  );
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}`);
104
112
  for (const entityId of credential.entityIds) {
105
113
  await this.moduleRepository.unsetCredential(entityId);
106
114
  }
@@ -1,4 +1,6 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
3
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
2
4
 
3
5
  /**
4
6
  * Re-authorization of a credential the caller owns (Management API v2). It
@@ -14,7 +16,11 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
14
16
  { code: 'CREDENTIAL_TYPE_UNKNOWN' }
15
17
  );
16
18
  }
17
- return { entityType: credential.type, credentialId: String(credential.id) };
19
+ return {
20
+ entityType: credential.type,
21
+ credentialId: String(credential.id),
22
+ ownerId: credential.userId,
23
+ };
18
24
  }
19
25
 
20
26
  /** GET /api/v2/credentials/:credentialId/reauthorize */
@@ -25,7 +31,7 @@ class GetCredentialReauthorization {
25
31
  }
26
32
 
27
33
  async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
28
- const binding = await credentialEntityType(
34
+ const { ownerId, ...binding } = await credentialEntityType(
29
35
  this.getCredentialForUser,
30
36
  credentialId,
31
37
  user
@@ -42,17 +48,21 @@ class GetCredentialReauthorization {
42
48
 
43
49
  /** POST /api/v2/credentials/:credentialId/reauthorize */
44
50
  class ReauthorizeCredentialForUser {
45
- constructor({ getCredentialForUser, submitAuthorizationStep }) {
51
+ constructor({ getCredentialForUser, submitAuthorizationStep, scopePolicy }) {
52
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
46
53
  this.getCredentialForUser = getCredentialForUser;
47
54
  this.submitAuthorizationStep = submitAuthorizationStep;
48
55
  }
49
56
 
50
57
  async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
51
- const binding = await credentialEntityType(
58
+ const { ownerId, ...binding } = await credentialEntityType(
52
59
  this.getCredentialForUser,
53
60
  credentialId,
54
61
  user
55
62
  );
63
+ // 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}`);
56
66
  return this.submitAuthorizationStep.execute({
57
67
  user,
58
68
  ...binding,
@@ -279,9 +279,11 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
279
279
  } = useCases;
280
280
 
281
281
  // The owner of an integration the caller may reach (404 otherwise), which is
282
- // what the id-and-owner use cases below expect.
283
- const ownerOf = async (integrationId, user) =>
284
- (await getOwnedIntegrationRecord.execute(integrationId, user)).userId;
282
+ // what the id-and-owner use cases below expect. `manage: true` is for
283
+ // changing or deleting: an organization's integration needs an admin (403).
284
+ const ownerOf = async (integrationId, user, options) =>
285
+ (await getOwnedIntegrationRecord.execute(integrationId, user, options))
286
+ .userId;
285
287
 
286
288
  router.route('/api/integrations').get(
287
289
  catchAsyncError(async (req, res) => {
@@ -333,7 +335,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
333
335
 
334
336
  const integration = await updateIntegration.execute(
335
337
  req.params.integrationId,
336
- await ownerOf(req.params.integrationId, user),
338
+ await ownerOf(req.params.integrationId, user, { manage: true }),
337
339
  params.config
338
340
  );
339
341
  res.json(integration);
@@ -346,7 +348,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
346
348
  const params = checkRequiredParams(req.params, ['integrationId']);
347
349
  await deleteIntegrationForUser.execute(
348
350
  params.integrationId,
349
- await ownerOf(params.integrationId, user)
351
+ await ownerOf(params.integrationId, user, { manage: true })
350
352
  );
351
353
  res.status(204).json({});
352
354
  })
@@ -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
  }
@@ -252,6 +252,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
252
252
  credentialRepository,
253
253
  moduleRepository,
254
254
  getCredentialForUser,
255
+ scopePolicy,
255
256
  }),
256
257
  getCredentialReauthorization: new GetCredentialReauthorization({
257
258
  getCredentialForUser,
@@ -260,6 +261,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
260
261
  reauthorizeCredentialForUser: new ReauthorizeCredentialForUser({
261
262
  getCredentialForUser,
262
263
  submitAuthorizationStep,
264
+ scopePolicy,
263
265
  }),
264
266
 
265
267
  // Authorization (multi-step)
@@ -8,6 +8,7 @@ const {
8
8
  SCOPES,
9
9
  INDIVIDUAL_ACROSS_VALUES,
10
10
  OWNER_RULES,
11
+ DEFAULT_ORG_ADMIN_ROLES,
11
12
  configError,
12
13
  isPlainObject,
13
14
  parseScopeSetting,
@@ -75,6 +76,26 @@ function readIntegrationOwnership(integrationClass) {
75
76
  return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner };
76
77
  }
77
78
 
79
+ /**
80
+ * `user.orgAdminRoles`: the token roles that may manage organization-level
81
+ * things. Default `['ADMIN']`. A shared-secret caller needs no role.
82
+ */
83
+ function readOrgAdminRoles(appDefinition) {
84
+ const raw = appDefinition?.user?.orgAdminRoles;
85
+ if (raw === undefined || raw === null) return [...DEFAULT_ORG_ADMIN_ROLES];
86
+ if (
87
+ !Array.isArray(raw) ||
88
+ raw.length === 0 ||
89
+ raw.some((role) => typeof role !== 'string' || role.trim() === '')
90
+ ) {
91
+ throw configError(
92
+ 'appDefinition.user.orgAdminRoles',
93
+ 'must be a non-empty array of role names'
94
+ );
95
+ }
96
+ return [...raw];
97
+ }
98
+
78
99
  /**
79
100
  * Every binding of every integration with its resolved scope and where it came
80
101
  * from, parsing each integration's settings once. Throws OwnershipConfigError
@@ -118,7 +139,7 @@ function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
118
139
  * Validates every ownership setting in one pass and returns the app-level
119
140
  * config the runtime needs.
120
141
  *
121
- * @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string}}
142
+ * @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[]}}
122
143
  * @throws {OwnershipConfigError}
123
144
  */
124
145
  function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
@@ -143,6 +164,7 @@ function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
143
164
  return Object.freeze({
144
165
  scope: app.scope,
145
166
  individualAcross: app.individualAcross,
167
+ orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
146
168
  });
147
169
  }
148
170
 
@@ -6,16 +6,21 @@
6
6
 
7
7
  const {
8
8
  INDIVIDUAL_ACROSS_VALUES,
9
+ DEFAULT_ORG_ADMIN_ROLES,
9
10
  configError,
10
11
  parseScopeSetting,
11
12
  resolveScope,
12
13
  resolveOwnerForScope,
13
14
  } = require('./scope-resolution');
14
- const { accessContext, describeRecord } = require('./scope-visibility');
15
+ const {
16
+ accessContext,
17
+ accessToOwner,
18
+ describeRecord,
19
+ } = require('./scope-visibility');
15
20
 
16
21
  /**
17
22
  * @param {Object} [params]
18
- * @param {{scope: *, individualAcross: string}} [params.ownership] - the raw
23
+ * @param {{scope: *, individualAcross: string, orgAdminRoles?: string[]}} [params.ownership] - the raw
19
24
  * `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
20
25
  * `{ default, allow }` parses to itself)
21
26
  * @param {Map} [params.entityTypeRegistry]
@@ -33,7 +38,10 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
33
38
  );
34
39
  }
35
40
 
36
- const contextFor = (user) => accessContext(user, { individualAcross });
41
+ const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
42
+
43
+ const contextFor = (user) =>
44
+ accessContext(user, { individualAcross, orgAdminRoles });
37
45
 
38
46
  /** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
39
47
  const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
@@ -65,8 +73,16 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
65
73
  const canUseEntity = (user, entity) =>
66
74
  describeRecord(entity, contextFor(user), 'entity') !== null;
67
75
 
76
+ /**
77
+ * What `user` may do with a record owned by `ownerId` that has no
78
+ * organization of its own (a credential): manage, or null when not theirs.
79
+ */
80
+ const accessToOwnedBy = (user, ownerId) => accessToOwner(ownerId, contextFor(user));
81
+
68
82
  return Object.freeze({
69
83
  individualAcross,
84
+ orgAdminRoles,
85
+ accessToOwnedBy,
70
86
  accessContext: contextFor,
71
87
  resolveScope: resolveEntityScope,
72
88
  resolveOwnership,
@@ -25,6 +25,9 @@ const INDIVIDUAL_ACROSS_VALUES = Object.freeze(['organization', 'all']);
25
25
  const OWNER_RULES = Object.freeze(['derive', 'individual', 'organization']);
26
26
  const ACCESS = Object.freeze({ MANAGE: 'manage', USE: 'use', VIEW: 'view' });
27
27
 
28
+ /** The organization roles that may manage organization-level things unless `user.orgAdminRoles` says. */
29
+ const DEFAULT_ORG_ADMIN_ROLES = Object.freeze(['ADMIN']);
30
+
28
31
  /** Raised at app-definition load for an invalid ownership setting. */
29
32
  class OwnershipConfigError extends BaseError {
30
33
  constructor(message, options) {
@@ -180,6 +183,11 @@ const MISSING_ORGANIZATION =
180
183
  const missing = (message, field) =>
181
184
  Boom.badRequest(message, { code: 'IDENTITY_REQUIRED', details: { field } });
182
185
 
186
+ const adminRequired = (what) =>
187
+ Boom.forbidden(`only an organization admin can ${what}`, {
188
+ code: 'ORG_ADMIN_REQUIRED',
189
+ });
190
+
183
191
  /**
184
192
  * The owner and organization for a new entity of `scope` (§3.2):
185
193
  *
@@ -200,6 +208,7 @@ function resolveOwnerForScope(scope, ctx) {
200
208
  }
201
209
  if (scope === SCOPES.ORGANIZATION) {
202
210
  if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid');
211
+ if (!ctx.canManageOrg) throw adminRequired('connect an organization-level account');
203
212
  return { scope, ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId };
204
213
  }
205
214
  throw Boom.forbidden(
@@ -229,6 +238,7 @@ function integrationOwnerKind(entityScopes, rule = 'derive') {
229
238
  function resolveIntegrationOwner(kind, ctx) {
230
239
  if (kind === SCOPES.ORGANIZATION) {
231
240
  if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid');
241
+ if (!ctx.canManageOrg) throw adminRequired('create an organization-level integration');
232
242
  return { ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId };
233
243
  }
234
244
  if (!ctx.callerId) throw missing(MISSING_INDIVIDUAL, 'x-frigg-appuserid');
@@ -263,6 +273,7 @@ module.exports = {
263
273
  INDIVIDUAL_ACROSS_VALUES,
264
274
  OWNER_RULES,
265
275
  ACCESS,
276
+ DEFAULT_ORG_ADMIN_ROLES,
266
277
  OwnershipConfigError,
267
278
  configError,
268
279
  isPlainObject,
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  const { isPresent, idString, sameId } = require('../user/membership');
11
- const { SCOPES, ACCESS } = require('./scope-resolution');
11
+ const { AUTH_SOURCES } = require('../user/user');
12
+ const { SCOPES, ACCESS, DEFAULT_ORG_ADMIN_ROLES } = require('./scope-resolution');
12
13
 
13
14
  /**
14
15
  * One owner/organization pair a repository ORs into a single query.
@@ -66,15 +67,35 @@ function buildClauses(base, kind) {
66
67
  return clauses;
67
68
  }
68
69
 
70
+ /**
71
+ * Whether the caller may manage organization-level things (create, change,
72
+ * delete). A shared-secret caller is the app's own backend and may. A person
73
+ * with a token has the role the token carries, and `MEMBER` when it carries
74
+ * none: a missing role never means admin. An unknown source (a `User` built
75
+ * outside AuthenticateUser) is treated like a token.
76
+ *
77
+ * @param {{authSource: (string|null), orgRole: (string|null)}} who
78
+ * @param {string[]} adminRoles - `user.orgAdminRoles`, compared case-insensitively
79
+ */
80
+ function canManageOrganization({ authSource, orgRole }, adminRoles = DEFAULT_ORG_ADMIN_ROLES) {
81
+ if (authSource === AUTH_SOURCES.SHARED_SECRET) return true;
82
+ const role = String(isPresent(orgRole) ? orgRole : 'MEMBER').toUpperCase();
83
+ return adminRoles.some((admin) => String(admin).toUpperCase() === role);
84
+ }
85
+
69
86
  /**
70
87
  * Who the caller is, for ownership and visibility: their individual user, the
71
- * organization this request acts in, the legacy `organizationId` pointer, and
72
- * the clauses that follow from them (computed once).
88
+ * organization this request acts in, the legacy `organizationId` pointer,
89
+ * whether they may manage organization-level things, and the clauses that
90
+ * follow from them (computed once).
73
91
  *
74
92
  * @param {Object} user - the authenticated `User` (or a `{ getId }` double)
75
- * @param {{individualAcross?: string}} [options]
93
+ * @param {{individualAcross?: string, orgAdminRoles?: string[]}} [options]
76
94
  */
77
- function accessContext(user, { individualAcross = 'organization' } = {}) {
95
+ function accessContext(
96
+ user,
97
+ { individualAcross = 'organization', orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES } = {}
98
+ ) {
78
99
  const hasUserModel = typeof user?.getIndividualUser === 'function';
79
100
  const individual = hasUserModel ? user.getIndividualUser() : null;
80
101
  const base = {
@@ -86,8 +107,14 @@ function accessContext(user, { individualAcross = 'organization' } = {}) {
86
107
  legacyOrgId: idString(individual?.organizationId),
87
108
  individualAcross,
88
109
  };
110
+ const who = {
111
+ authSource:
112
+ typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
113
+ orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
114
+ };
89
115
  return {
90
116
  ...base,
117
+ canManageOrg: canManageOrganization(who, orgAdminRoles),
91
118
  clauses: {
92
119
  entity: buildClauses(base, 'entity'),
93
120
  integration: buildClauses(base, 'integration'),
@@ -112,8 +139,26 @@ function scopeOf(record, ctx) {
112
139
  return null;
113
140
  }
114
141
 
115
- const accessFor = (scope) =>
116
- scope === SCOPES.GLOBAL ? ACCESS.USE : ACCESS.MANAGE;
142
+ /** manage for the person's own; for the organization's, only an admin; use otherwise. */
143
+ const accessFor = (scope, ctx) => {
144
+ if (scope === SCOPES.INDIVIDUAL) return ACCESS.MANAGE;
145
+ if (scope === SCOPES.ORGANIZATION) {
146
+ return ctx.canManageOrg ? ACCESS.MANAGE : ACCESS.USE;
147
+ }
148
+ return ACCESS.USE;
149
+ };
150
+
151
+ /**
152
+ * What the caller may do with a record owned by `ownerId` that carries no
153
+ * organization of its own (a credential): manage the person's own, manage the
154
+ * active organization's only as an admin, nothing otherwise. Null when the
155
+ * record is not theirs.
156
+ */
157
+ function accessToOwner(ownerId, ctx) {
158
+ const scope = scopeOf({ userId: ownerId }, ctx);
159
+ if (scope !== SCOPES.INDIVIDUAL && scope !== SCOPES.ORGANIZATION) return null;
160
+ return accessFor(scope, ctx);
161
+ }
117
162
 
118
163
  /**
119
164
  * What a row is to this caller, or null when they may not see it.
@@ -134,7 +179,7 @@ function describeRecord(record, ctx, kind) {
134
179
  scope === SCOPES.ORGANIZATION
135
180
  ? idString(record.userId)
136
181
  : idString(record.organizationId),
137
- access: accessFor(scope),
182
+ access: accessFor(scope, ctx),
138
183
  };
139
184
  }
140
185
 
@@ -154,6 +199,8 @@ function matchesListFilters(description, { scope, owner } = {}) {
154
199
  }
155
200
 
156
201
  module.exports = {
202
+ canManageOrganization,
203
+ accessToOwner,
157
204
  accessContext,
158
205
  unscopedRowsBelongHere,
159
206
  visibilityClauses,
@@ -5,6 +5,7 @@ const {
5
5
  matchesListFilters,
6
6
  } = require('../scope-visibility');
7
7
  const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
8
+ const { assertCanManage } = require('./manage-access');
8
9
 
9
10
  /**
10
11
  * Management API v2 entity operations. Each one starts from
@@ -111,10 +112,10 @@ class RefreshEntityOptionsForUser {
111
112
  }
112
113
 
113
114
  /**
114
- * Deletes an entity the caller may use. Refuses with 409 while integrations
115
- * still use it, so an integration never points at a missing entity. The
116
- * credential is left alone (others may share it); delete it through
117
- * /api/v2/credentials.
115
+ * Deletes an entity the caller may manage (the owner; for an organization
116
+ * entity, an organization admin). Refuses with 409 while integrations still
117
+ * use it, so an integration never points at a missing entity. The credential
118
+ * is left alone (others may share it); delete it through /api/v2/credentials.
118
119
  */
119
120
  class DeleteEntityForUser {
120
121
  constructor({ getEntityModuleForUser, moduleRepository, integrationRepository }) {
@@ -128,6 +129,10 @@ class DeleteEntityForUser {
128
129
  entityId,
129
130
  user
130
131
  );
132
+ assertCanManage(
133
+ this.getEntityModuleForUser.describe(entity, user)?.access,
134
+ `entity ${entity.id}`
135
+ );
131
136
  const integrations =
132
137
  (await this.integrationRepository.findIntegrationsByEntityId(
133
138
  entity.id
@@ -0,0 +1,21 @@
1
+ const Boom = require('@hapi/boom');
2
+ const { ACCESS } = require('../scope-resolution');
3
+
4
+ /**
5
+ * The one check behind every "may this caller change or delete it" gate
6
+ * (ADR-056 §3 permissions): the caller may use it but not manage it, so 403
7
+ * `ORG_ADMIN_REQUIRED`. `access` comes from describeRecord (entities,
8
+ * integrations) or scopePolicy.accessToOwnedBy (credentials).
9
+ *
10
+ * @param {string|null} access - ACCESS value
11
+ * @param {string} subject - e.g. `entity 5`, for the message
12
+ */
13
+ function assertCanManage(access, subject) {
14
+ if (access !== ACCESS.MANAGE) {
15
+ throw Boom.forbidden(`You may use ${subject} but not change or delete it`, {
16
+ code: 'ORG_ADMIN_REQUIRED',
17
+ });
18
+ }
19
+ }
20
+
21
+ module.exports = { assertCanManage };
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.682.cbca9e2.0",
4
+ "version": "2.0.0--canary.683.f45da33.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.682.cbca9e2.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.682.cbca9e2.0",
53
- "@friggframework/test": "2.0.0--canary.682.cbca9e2.0",
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",
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": "cbca9e22cf6af8765f83f03b2e6d3700d1e149b5"
93
+ "gitHead": "f45da337e62262988666a166b3d721020dae9161"
94
94
  }
@@ -62,6 +62,13 @@ declare module "@friggframework/core/user" {
62
62
  * at startup. `strictMembership` wins when both are set.
63
63
  */
64
64
  strictUserValidation?: boolean;
65
+ /**
66
+ * Roles (the adopter token's role claim) that may manage organization-level
67
+ * things (ADR-056 §3). Default `['ADMIN']`; a token with no role claim is
68
+ * `MEMBER`, never admin; a shared-secret (`x-frigg` header) caller needs no
69
+ * role. Compared case-insensitively. Validated at load.
70
+ */
71
+ orgAdminRoles?: string[];
65
72
  authModes?: UserAuthModes;
66
73
  jwtConfig?: AdopterJwtConfig;
67
74
  }
@@ -1,5 +1,6 @@
1
1
  const Boom = require('@hapi/boom');
2
2
  const { getLogger } = require('../../logs');
3
+ const { AUTH_SOURCES } = require('../user');
3
4
 
4
5
  const log = getLogger('frigg.user.authentication');
5
6
 
@@ -68,10 +69,13 @@ class AuthenticateUser {
68
69
  // Validate the API key (authentication)
69
70
  await this.authenticateWithSharedSecret.execute(apiKey);
70
71
  // Get user from x-frigg headers (authorization)
71
- return await this.getUserFromXFriggHeaders.execute(
72
+ const user = await this.getUserFromXFriggHeaders.execute(
72
73
  appUserId,
73
74
  appOrgId
74
75
  );
76
+ // The app's own backend: trusted to manage organization things.
77
+ user.setAuthSource(AUTH_SOURCES.SHARED_SECRET);
78
+ return user;
75
79
  }
76
80
  log.debug('No x-frigg-api-key header, skipping shared secret', {
77
81
  eventName: `${log.name}.shared_secret_skipped`,
@@ -90,6 +94,7 @@ class AuthenticateUser {
90
94
  // Detect JWT format (3 parts separated by dots)
91
95
  if (token && token.split('.').length === 3) {
92
96
  const user = await this.getUserFromAdopterJwt.execute(token);
97
+ user.setAuthSource(AUTH_SOURCES.ADOPTER_JWT);
93
98
  // Validate x-frigg headers match JWT claims if present
94
99
  if (appUserId || appOrgId) {
95
100
  this.validateUserMatch(user, appUserId, appOrgId);
@@ -106,6 +111,7 @@ class AuthenticateUser {
106
111
  const user = await this.getUserFromBearerToken.execute(
107
112
  req.headers.authorization
108
113
  );
114
+ user.setAuthSource(AUTH_SOURCES.BEARER_TOKEN);
109
115
  // Validate x-frigg headers match token user if present
110
116
  if (appUserId || appOrgId) {
111
117
  this.validateUserMatch(user, appUserId, appOrgId);
package/user/user.js CHANGED
@@ -1,5 +1,18 @@
1
1
  const bcrypt = require('bcryptjs');
2
2
 
3
+ /**
4
+ * How the request that produced a `User` proved who it is. Set by
5
+ * AuthenticateUser, never inferred from a missing value. Organization roles
6
+ * (ADR-056 §3, §3.6) depend on it: a shared-secret caller is the app's own
7
+ * backend and is trusted to manage; a person with a token has the role the
8
+ * token carries, or `MEMBER`.
9
+ */
10
+ const AUTH_SOURCES = Object.freeze({
11
+ SHARED_SECRET: 'shared-secret',
12
+ ADOPTER_JWT: 'adopter-jwt',
13
+ BEARER_TOKEN: 'bearer-token',
14
+ });
15
+
3
16
  /**
4
17
  * Represents a user in the system. The User class is a domain entity,
5
18
  * @class User
@@ -21,6 +34,8 @@ class User {
21
34
  // Organization role from an adopter token claim (ADR-056 §2). Per request:
22
35
  // the role stored on a membership is only what the first token carried.
23
36
  this.orgRole = null;
37
+ // How this request authenticated (one of AUTH_SOURCES); null until AuthenticateUser says.
38
+ this.authSource = null;
24
39
 
25
40
  this.config = {
26
41
  primary,
@@ -84,6 +99,14 @@ class User {
84
99
  return this.config.individualUserRequired;
85
100
  }
86
101
 
102
+ getAuthSource() {
103
+ return this.authSource;
104
+ }
105
+
106
+ setAuthSource(authSource) {
107
+ this.authSource = authSource ?? null;
108
+ }
109
+
87
110
  getOrgRole() {
88
111
  return this.orgRole;
89
112
  }
@@ -149,4 +172,4 @@ class User {
149
172
  }
150
173
  }
151
174
 
152
- module.exports = { User };
175
+ module.exports = { User, AUTH_SOURCES };