@friggframework/core 2.0.0--canary.682.3c8b68a.0 → 2.0.0--canary.683.8a84ab1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/credential/use-cases/credential-operations-for-user.js +9 -1
- package/credential/use-cases/reauthorize-credential-for-user.js +14 -4
- package/index.js +3 -0
- package/integrations/index.js +5 -0
- package/integrations/integration-router.js +33 -33
- package/integrations/repositories/integration-repository-documentdb.js +2 -1
- package/integrations/use-cases/integration-operations-for-user.js +16 -6
- package/integrations/utils/map-integration-dto.js +44 -1
- package/management-api/v2/dependencies.js +2 -0
- package/modules/repositories/module-repository-documentdb.js +6 -3
- package/modules/scope-config.js +38 -1
- package/modules/scope-policy.js +20 -3
- package/modules/scope-resolution.js +11 -0
- package/modules/scope-visibility.js +82 -8
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +9 -4
- package/modules/use-cases/get-entity-options-by-id.js +4 -1
- package/modules/use-cases/get-module.js +4 -1
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/refresh-entity-options.js +4 -1
- package/modules/use-cases/test-module-auth.js +4 -1
- package/package.json +5 -5
- package/types/modules/index.d.ts +10 -0
- package/types/user/index.d.ts +17 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/get-user-from-adopter-jwt.js +17 -7
- package/user/user.js +39 -3
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,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 {
|
|
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,
|
package/index.js
CHANGED
|
@@ -71,6 +71,7 @@ const {
|
|
|
71
71
|
Options,
|
|
72
72
|
createIntegrationRouter,
|
|
73
73
|
checkRequiredParams,
|
|
74
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
74
75
|
LoadIntegrationContextUseCase,
|
|
75
76
|
} = require('./integrations/index');
|
|
76
77
|
const {
|
|
@@ -166,6 +167,8 @@ module.exports = {
|
|
|
166
167
|
Options,
|
|
167
168
|
checkRequiredParams,
|
|
168
169
|
createIntegrationRouter,
|
|
170
|
+
// Deprecated (ADR-059): use getEntityTypeRegistry.
|
|
171
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
169
172
|
LoadIntegrationContextUseCase,
|
|
170
173
|
CreateProcess,
|
|
171
174
|
UpdateProcessState,
|
package/integrations/index.js
CHANGED
|
@@ -4,6 +4,9 @@ const {
|
|
|
4
4
|
createIntegrationRouter,
|
|
5
5
|
checkRequiredParams,
|
|
6
6
|
} = require('./integration-router');
|
|
7
|
+
const {
|
|
8
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
9
|
+
} = require('./utils/map-integration-dto');
|
|
7
10
|
const {
|
|
8
11
|
LoadIntegrationContextUseCase,
|
|
9
12
|
} = require('./use-cases/load-integration-context');
|
|
@@ -18,6 +21,8 @@ module.exports = {
|
|
|
18
21
|
Options,
|
|
19
22
|
createIntegrationRouter,
|
|
20
23
|
checkRequiredParams,
|
|
24
|
+
// Deprecated (ADR-059): a wrapper over getEntityTypeRegistry.
|
|
25
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
21
26
|
LoadIntegrationContextUseCase,
|
|
22
27
|
validateExtensionBinding,
|
|
23
28
|
getExtensionRoutes,
|
|
@@ -65,6 +65,9 @@ const { createScopePolicy } = require('../modules/scope-policy');
|
|
|
65
65
|
const {
|
|
66
66
|
AuthorizeEntityForUser,
|
|
67
67
|
} = require('../modules/use-cases/authorize-entity-for-user');
|
|
68
|
+
const {
|
|
69
|
+
CreateEntityFromCredential,
|
|
70
|
+
} = require('../modules/use-cases/create-entity-from-credential');
|
|
68
71
|
const {
|
|
69
72
|
ResolveIntegrationOwnership,
|
|
70
73
|
} = require('./use-cases/resolve-integration-ownership');
|
|
@@ -199,14 +202,22 @@ function createIntegrationRouter() {
|
|
|
199
202
|
});
|
|
200
203
|
|
|
201
204
|
// The owner of what an authorization creates follows its scope (ADR-056 §3.2).
|
|
205
|
+
const processAuthorizationCallback = new ProcessAuthorizationCallback({
|
|
206
|
+
moduleRepository,
|
|
207
|
+
credentialRepository,
|
|
208
|
+
integrationRepository,
|
|
209
|
+
moduleDefinitions,
|
|
210
|
+
});
|
|
202
211
|
const authorizeEntityForUser = new AuthorizeEntityForUser({
|
|
203
212
|
scopePolicy,
|
|
204
|
-
processAuthorizationCallback
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
processAuthorizationCallback,
|
|
214
|
+
});
|
|
215
|
+
// v1 POST /api/entity: same owner resolution as /api/authorize.
|
|
216
|
+
const createEntityFromCredential = new CreateEntityFromCredential({
|
|
217
|
+
authorizeEntityForUser,
|
|
218
|
+
processAuthorizationCallback,
|
|
219
|
+
credentialRepository,
|
|
220
|
+
moduleDefinitions,
|
|
210
221
|
});
|
|
211
222
|
|
|
212
223
|
const router = express();
|
|
@@ -231,6 +242,7 @@ function createIntegrationRouter() {
|
|
|
231
242
|
getEntityOptionsById,
|
|
232
243
|
refreshEntityOptions,
|
|
233
244
|
authorizeEntityForUser,
|
|
245
|
+
createEntityFromCredential,
|
|
234
246
|
});
|
|
235
247
|
return router;
|
|
236
248
|
}
|
|
@@ -279,9 +291,11 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
279
291
|
} = useCases;
|
|
280
292
|
|
|
281
293
|
// The owner of an integration the caller may reach (404 otherwise), which is
|
|
282
|
-
// what the id-and-owner use cases below expect.
|
|
283
|
-
|
|
284
|
-
|
|
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;
|
|
285
299
|
|
|
286
300
|
router.route('/api/integrations').get(
|
|
287
301
|
catchAsyncError(async (req, res) => {
|
|
@@ -333,7 +347,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
333
347
|
|
|
334
348
|
const integration = await updateIntegration.execute(
|
|
335
349
|
req.params.integrationId,
|
|
336
|
-
await ownerOf(req.params.integrationId, user),
|
|
350
|
+
await ownerOf(req.params.integrationId, user, { manage: true }),
|
|
337
351
|
params.config
|
|
338
352
|
);
|
|
339
353
|
res.json(integration);
|
|
@@ -346,7 +360,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
346
360
|
const params = checkRequiredParams(req.params, ['integrationId']);
|
|
347
361
|
await deleteIntegrationForUser.execute(
|
|
348
362
|
params.integrationId,
|
|
349
|
-
await ownerOf(params.integrationId, user)
|
|
363
|
+
await ownerOf(params.integrationId, user, { manage: true })
|
|
350
364
|
);
|
|
351
365
|
res.status(204).json({});
|
|
352
366
|
})
|
|
@@ -527,6 +541,7 @@ function setEntityRoutes(router, authenticateUser, useCases) {
|
|
|
527
541
|
getEntityOptionsById,
|
|
528
542
|
refreshEntityOptions,
|
|
529
543
|
authorizeEntityForUser,
|
|
544
|
+
createEntityFromCredential,
|
|
530
545
|
} = useCases;
|
|
531
546
|
|
|
532
547
|
router.route('/api/authorize').get(
|
|
@@ -601,35 +616,20 @@ function setEntityRoutes(router, authenticateUser, useCases) {
|
|
|
601
616
|
router.route('/api/entity').post(
|
|
602
617
|
catchAsyncError(async (req, res) => {
|
|
603
618
|
const user = await authenticateUser.execute(req);
|
|
604
|
-
const userId = user.getId();
|
|
605
619
|
const params = checkRequiredParams(req.body, [
|
|
606
620
|
'entityType',
|
|
607
621
|
'data',
|
|
608
622
|
]);
|
|
609
623
|
checkRequiredParams(req.body.data, ['credential_id']);
|
|
610
624
|
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
throw Boom.badRequest('Invalid credential ID');
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
const module = await getModuleInstanceFromType.execute(
|
|
622
|
-
userId,
|
|
623
|
-
params.entityType
|
|
624
|
-
);
|
|
625
|
-
const entityDetails = await module.getEntityDetails(
|
|
626
|
-
module.api,
|
|
627
|
-
null,
|
|
628
|
-
null,
|
|
629
|
-
userId
|
|
625
|
+
// The owner follows the entity type's scope, as /api/authorize.
|
|
626
|
+
res.json(
|
|
627
|
+
await createEntityFromCredential.execute({
|
|
628
|
+
user,
|
|
629
|
+
entityType: params.entityType,
|
|
630
|
+
credentialId: params.data.credential_id,
|
|
631
|
+
})
|
|
630
632
|
);
|
|
631
|
-
|
|
632
|
-
res.json(await module.findOrCreateEntity(entityDetails));
|
|
633
633
|
})
|
|
634
634
|
);
|
|
635
635
|
|
|
@@ -40,7 +40,8 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
|
|
|
40
40
|
*/
|
|
41
41
|
async findIntegrationsByOwnership(clauses) {
|
|
42
42
|
if (!Array.isArray(clauses) || clauses.length === 0) return [];
|
|
43
|
-
|
|
43
|
+
// Drained: a list can hold more than the first cursor batch (~101).
|
|
44
|
+
const records = await findManyDrained(this.prisma, 'Integration', {
|
|
44
45
|
$or: clauses.map(({ userId, organizationId }) => ({
|
|
45
46
|
userId: toObjectId(userId),
|
|
46
47
|
organizationId:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
}
|
|
@@ -19,5 +19,48 @@ function mapIntegrationClassToIntegrationDTO(integration) {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
let deprecationLogged = false;
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* @deprecated Since ADR-059. Read `entityTypeRegistry` / `moduleDefinitions`
|
|
26
|
+
* from `loadAppDefinition()`, or call `getEntityTypeRegistry(appDefinition)`
|
|
27
|
+
* and read each entry's `definition`.
|
|
28
|
+
*
|
|
29
|
+
* Kept as a wrapper over the entity type registry so callers that imported it
|
|
30
|
+
* keep working. It returns the registry's definitions, so it follows the
|
|
31
|
+
* registry's rules: one definition per entity type name (the first wins when
|
|
32
|
+
* two integrations bind different definitions under one name, as
|
|
33
|
+
* ModuleFactory already picked), and, when `entities` is passed, registry-only
|
|
34
|
+
* types are included and conflicts throw `EntityRegistryError`.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array<Function>} integrationClasses - integration classes
|
|
37
|
+
* @param {Object} [entities] - `appDefinition.entities` (ADR-059)
|
|
38
|
+
* @returns {Array<Object>} API module definitions
|
|
39
|
+
*/
|
|
40
|
+
function getModulesDefinitionFromIntegrationClasses(
|
|
41
|
+
integrationClasses,
|
|
42
|
+
entities
|
|
43
|
+
) {
|
|
44
|
+
// Required here, not at the top: modules/ must not load at require time
|
|
45
|
+
// of every integration use case that only wants the DTO mapper.
|
|
46
|
+
const {
|
|
47
|
+
getEntityTypeRegistry,
|
|
48
|
+
getEntityTypeDefinitions,
|
|
49
|
+
} = require('../../modules/entity-type-registry');
|
|
50
|
+
if (!deprecationLogged) {
|
|
51
|
+
deprecationLogged = true;
|
|
52
|
+
const { getLogger } = require('../../logs');
|
|
53
|
+
getLogger('frigg.integrations').warn(
|
|
54
|
+
'getModulesDefinitionFromIntegrationClasses is deprecated; use getEntityTypeRegistry (ADR-059)',
|
|
55
|
+
{ eventName: 'frigg.integrations.deprecated_module_definitions' }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return getEntityTypeDefinitions(
|
|
59
|
+
getEntityTypeRegistry({ integrations: integrationClasses, entities })
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
mapIntegrationClassToIntegrationDTO,
|
|
65
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
66
|
+
};
|
|
@@ -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)
|
|
@@ -3,6 +3,7 @@ const {
|
|
|
3
3
|
toObjectId,
|
|
4
4
|
fromObjectId,
|
|
5
5
|
findMany,
|
|
6
|
+
findManyDrained,
|
|
6
7
|
findOne,
|
|
7
8
|
insertOne,
|
|
8
9
|
updateOne,
|
|
@@ -87,7 +88,8 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
|
|
|
87
88
|
*/
|
|
88
89
|
async findEntitiesByOwnership(clauses) {
|
|
89
90
|
if (!Array.isArray(clauses) || clauses.length === 0) return [];
|
|
90
|
-
|
|
91
|
+
// Drained: a list can hold more than the first cursor batch (~101).
|
|
92
|
+
const docs = await findManyDrained(this.prisma, 'Entity', {
|
|
91
93
|
$or: clauses.map(({ userId, organizationId }) => ({
|
|
92
94
|
userId: toObjectId(userId),
|
|
93
95
|
organizationId:
|
|
@@ -277,8 +279,9 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
|
|
|
277
279
|
const objectIds = ids.map(id => toObjectId(id)).filter(Boolean);
|
|
278
280
|
if (objectIds.length === 0) return new Map();
|
|
279
281
|
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
+
// Raw read to bypass the Prisma encryption extension. Drained:
|
|
283
|
+
// more ids than the first cursor batch must all come back.
|
|
284
|
+
const rawCredentials = await findManyDrained(this.prisma, 'Credential', {
|
|
282
285
|
_id: { $in: objectIds }
|
|
283
286
|
});
|
|
284
287
|
|
package/modules/scope-config.js
CHANGED
|
@@ -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,40 @@ 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
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `user.orgAdminGateBearerTokens`: judge Frigg bearer-token callers by role
|
|
101
|
+
* like adopter-token callers. Default false: they keep their pre-roles
|
|
102
|
+
* behaviour and may manage the organization they act in.
|
|
103
|
+
*/
|
|
104
|
+
function readGateBearerTokens(appDefinition) {
|
|
105
|
+
const raw = appDefinition?.user?.orgAdminGateBearerTokens;
|
|
106
|
+
if (raw === undefined || raw === null) return false;
|
|
107
|
+
if (typeof raw !== 'boolean') {
|
|
108
|
+
throw configError('appDefinition.user.orgAdminGateBearerTokens', 'must be a boolean');
|
|
109
|
+
}
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
/**
|
|
79
114
|
* Every binding of every integration with its resolved scope and where it came
|
|
80
115
|
* from, parsing each integration's settings once. Throws OwnershipConfigError
|
|
@@ -118,7 +153,7 @@ function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
|
|
|
118
153
|
* Validates every ownership setting in one pass and returns the app-level
|
|
119
154
|
* config the runtime needs.
|
|
120
155
|
*
|
|
121
|
-
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string}}
|
|
156
|
+
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[], gateBearerTokens: boolean}}
|
|
122
157
|
* @throws {OwnershipConfigError}
|
|
123
158
|
*/
|
|
124
159
|
function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
@@ -143,6 +178,8 @@ function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
|
143
178
|
return Object.freeze({
|
|
144
179
|
scope: app.scope,
|
|
145
180
|
individualAcross: app.individualAcross,
|
|
181
|
+
orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
|
|
182
|
+
gateBearerTokens: readGateBearerTokens(appDefinition),
|
|
146
183
|
});
|
|
147
184
|
}
|
|
148
185
|
|
package/modules/scope-policy.js
CHANGED
|
@@ -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 {
|
|
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[], gateBearerTokens?: boolean}} [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,11 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
33
38
|
);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
const
|
|
41
|
+
const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
|
|
42
|
+
const gateBearerTokens = ownership?.gateBearerTokens === true;
|
|
43
|
+
|
|
44
|
+
const contextFor = (user) =>
|
|
45
|
+
accessContext(user, { individualAcross, orgAdminRoles, gateBearerTokens });
|
|
37
46
|
|
|
38
47
|
/** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
|
|
39
48
|
const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
|
|
@@ -65,8 +74,16 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
65
74
|
const canUseEntity = (user, entity) =>
|
|
66
75
|
describeRecord(entity, contextFor(user), 'entity') !== null;
|
|
67
76
|
|
|
77
|
+
/**
|
|
78
|
+
* What `user` may do with a record owned by `ownerId` that has no
|
|
79
|
+
* organization of its own (a credential): manage, or null when not theirs.
|
|
80
|
+
*/
|
|
81
|
+
const accessToOwnedBy = (user, ownerId) => accessToOwner(ownerId, contextFor(user));
|
|
82
|
+
|
|
68
83
|
return Object.freeze({
|
|
69
84
|
individualAcross,
|
|
85
|
+
orgAdminRoles,
|
|
86
|
+
accessToOwnedBy,
|
|
70
87
|
accessContext: contextFor,
|
|
71
88
|
resolveScope: resolveEntityScope,
|
|
72
89
|
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 {
|
|
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,56 @@ 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), ADR-056 §3.6:
|
|
73
|
+
*
|
|
74
|
+
* - A shared-secret caller is the app's own backend and may.
|
|
75
|
+
* - A Frigg bearer token (`/user/login`) predates organization roles and
|
|
76
|
+
* carries none. It keeps its pre-roles behaviour and may, unless the app
|
|
77
|
+
* sets `user.orgAdminGateBearerTokens: true`; then it is judged like any
|
|
78
|
+
* other caller below.
|
|
79
|
+
* - Otherwise the role is the one the token carries; when it carries none,
|
|
80
|
+
* the role stored on the caller's membership of the active organization;
|
|
81
|
+
* when there is none, `MEMBER`. A missing role never means admin. An
|
|
82
|
+
* unknown source (a `User` built outside AuthenticateUser) is judged this
|
|
83
|
+
* way too.
|
|
84
|
+
*
|
|
85
|
+
* @param {{authSource: (string|null), orgRole: (string|null), membershipRole: (string|null)}} who
|
|
86
|
+
* @param {string[]} adminRoles - `user.orgAdminRoles`, compared case-insensitively
|
|
87
|
+
* @param {{gateBearerTokens?: boolean}} [options]
|
|
88
|
+
*/
|
|
89
|
+
function canManageOrganization(
|
|
90
|
+
{ authSource, orgRole, membershipRole },
|
|
91
|
+
adminRoles = DEFAULT_ORG_ADMIN_ROLES,
|
|
92
|
+
{ gateBearerTokens = false } = {}
|
|
93
|
+
) {
|
|
94
|
+
if (authSource === AUTH_SOURCES.SHARED_SECRET) return true;
|
|
95
|
+
if (authSource === AUTH_SOURCES.BEARER_TOKEN && !gateBearerTokens) return true;
|
|
96
|
+
let role = 'MEMBER';
|
|
97
|
+
if (isPresent(orgRole)) role = orgRole;
|
|
98
|
+
else if (isPresent(membershipRole)) role = membershipRole;
|
|
99
|
+
role = String(role).toUpperCase();
|
|
100
|
+
return adminRoles.some((admin) => String(admin).toUpperCase() === role);
|
|
101
|
+
}
|
|
102
|
+
|
|
69
103
|
/**
|
|
70
104
|
* Who the caller is, for ownership and visibility: their individual user, the
|
|
71
|
-
* organization this request acts in, the legacy `organizationId` pointer,
|
|
72
|
-
*
|
|
105
|
+
* organization this request acts in, the legacy `organizationId` pointer,
|
|
106
|
+
* whether they may manage organization-level things, and the clauses that
|
|
107
|
+
* follow from them (computed once).
|
|
73
108
|
*
|
|
74
109
|
* @param {Object} user - the authenticated `User` (or a `{ getId }` double)
|
|
75
|
-
* @param {{individualAcross?: string}} [options]
|
|
110
|
+
* @param {{individualAcross?: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [options]
|
|
76
111
|
*/
|
|
77
|
-
function accessContext(
|
|
112
|
+
function accessContext(
|
|
113
|
+
user,
|
|
114
|
+
{
|
|
115
|
+
individualAcross = 'organization',
|
|
116
|
+
orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES,
|
|
117
|
+
gateBearerTokens = false,
|
|
118
|
+
} = {}
|
|
119
|
+
) {
|
|
78
120
|
const hasUserModel = typeof user?.getIndividualUser === 'function';
|
|
79
121
|
const individual = hasUserModel ? user.getIndividualUser() : null;
|
|
80
122
|
const base = {
|
|
@@ -86,8 +128,20 @@ function accessContext(user, { individualAcross = 'organization' } = {}) {
|
|
|
86
128
|
legacyOrgId: idString(individual?.organizationId),
|
|
87
129
|
individualAcross,
|
|
88
130
|
};
|
|
131
|
+
const who = {
|
|
132
|
+
authSource:
|
|
133
|
+
typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
|
|
134
|
+
orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
|
|
135
|
+
membershipRole:
|
|
136
|
+
typeof user?.getMembershipRole === 'function'
|
|
137
|
+
? user.getMembershipRole()
|
|
138
|
+
: null,
|
|
139
|
+
};
|
|
89
140
|
return {
|
|
90
141
|
...base,
|
|
142
|
+
canManageOrg: canManageOrganization(who, orgAdminRoles, {
|
|
143
|
+
gateBearerTokens,
|
|
144
|
+
}),
|
|
91
145
|
clauses: {
|
|
92
146
|
entity: buildClauses(base, 'entity'),
|
|
93
147
|
integration: buildClauses(base, 'integration'),
|
|
@@ -112,8 +166,26 @@ function scopeOf(record, ctx) {
|
|
|
112
166
|
return null;
|
|
113
167
|
}
|
|
114
168
|
|
|
115
|
-
|
|
116
|
-
|
|
169
|
+
/** manage for the person's own; for the organization's, only an admin; use otherwise. */
|
|
170
|
+
const accessFor = (scope, ctx) => {
|
|
171
|
+
if (scope === SCOPES.INDIVIDUAL) return ACCESS.MANAGE;
|
|
172
|
+
if (scope === SCOPES.ORGANIZATION) {
|
|
173
|
+
return ctx.canManageOrg ? ACCESS.MANAGE : ACCESS.USE;
|
|
174
|
+
}
|
|
175
|
+
return ACCESS.USE;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What the caller may do with a record owned by `ownerId` that carries no
|
|
180
|
+
* organization of its own (a credential): manage the person's own, manage the
|
|
181
|
+
* active organization's only as an admin, nothing otherwise. Null when the
|
|
182
|
+
* record is not theirs.
|
|
183
|
+
*/
|
|
184
|
+
function accessToOwner(ownerId, ctx) {
|
|
185
|
+
const scope = scopeOf({ userId: ownerId }, ctx);
|
|
186
|
+
if (scope !== SCOPES.INDIVIDUAL && scope !== SCOPES.ORGANIZATION) return null;
|
|
187
|
+
return accessFor(scope, ctx);
|
|
188
|
+
}
|
|
117
189
|
|
|
118
190
|
/**
|
|
119
191
|
* What a row is to this caller, or null when they may not see it.
|
|
@@ -134,7 +206,7 @@ function describeRecord(record, ctx, kind) {
|
|
|
134
206
|
scope === SCOPES.ORGANIZATION
|
|
135
207
|
? idString(record.userId)
|
|
136
208
|
: idString(record.organizationId),
|
|
137
|
-
access: accessFor(scope),
|
|
209
|
+
access: accessFor(scope, ctx),
|
|
138
210
|
};
|
|
139
211
|
}
|
|
140
212
|
|
|
@@ -154,6 +226,8 @@ function matchesListFilters(description, { scope, owner } = {}) {
|
|
|
154
226
|
}
|
|
155
227
|
|
|
156
228
|
module.exports = {
|
|
229
|
+
canManageOrganization,
|
|
230
|
+
accessToOwner,
|
|
157
231
|
accessContext,
|
|
158
232
|
unscopedRowsBelongHere,
|
|
159
233
|
visibilityClauses,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { Module } = require('../module');
|
|
3
|
+
|
|
4
|
+
const sameId = (a, b) =>
|
|
5
|
+
a !== undefined && a !== null && b !== undefined && b !== null &&
|
|
6
|
+
String(a) === String(b);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates (or finds) the entity for a credential the caller already holds:
|
|
10
|
+
* the v1 `POST /api/entity`. The owner and organization follow the scope of
|
|
11
|
+
* `entityType`, exactly as an authorization does (ADR-056 §3.2, §3.6), so the
|
|
12
|
+
* owner is decided by `AuthorizeEntityForUser.resolveOwnership` and never by
|
|
13
|
+
* `user.getId()`. Whatever that resolution refuses (a missing identity, and
|
|
14
|
+
* an organization-level connection by a non-admin) is refused here too.
|
|
15
|
+
*
|
|
16
|
+
* The credential must belong to that owner: an organization entity is built
|
|
17
|
+
* on the organization's credential, an individual one on the person's own.
|
|
18
|
+
*/
|
|
19
|
+
class CreateEntityFromCredential {
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} params
|
|
22
|
+
* @param {import('./authorize-entity-for-user').AuthorizeEntityForUser} params.authorizeEntityForUser
|
|
23
|
+
* @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback
|
|
24
|
+
* @param {Object} params.credentialRepository
|
|
25
|
+
* @param {Array<Object>} params.moduleDefinitions
|
|
26
|
+
* @param {Function} [params.createModule] - builds a Module; injectable for tests
|
|
27
|
+
*/
|
|
28
|
+
constructor({
|
|
29
|
+
authorizeEntityForUser,
|
|
30
|
+
processAuthorizationCallback,
|
|
31
|
+
credentialRepository,
|
|
32
|
+
moduleDefinitions,
|
|
33
|
+
createModule,
|
|
34
|
+
}) {
|
|
35
|
+
this.authorizeEntityForUser = authorizeEntityForUser;
|
|
36
|
+
this.processAuthorizationCallback = processAuthorizationCallback;
|
|
37
|
+
this.credentialRepository = credentialRepository;
|
|
38
|
+
this.moduleDefinitions = moduleDefinitions || [];
|
|
39
|
+
this.createModule = createModule || ((params) => new Module(params));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {Object} params
|
|
44
|
+
* @param {Object} params.user - authenticated User
|
|
45
|
+
* @param {string} params.entityType
|
|
46
|
+
* @param {string} params.credentialId
|
|
47
|
+
* @returns {Promise<Object>} the persisted entity
|
|
48
|
+
*/
|
|
49
|
+
async execute({ user, entityType, credentialId }) {
|
|
50
|
+
const definition = this.moduleDefinitions.find(
|
|
51
|
+
(def) =>
|
|
52
|
+
def.moduleName === entityType ||
|
|
53
|
+
(typeof def.getName === 'function' && def.getName() === entityType)
|
|
54
|
+
);
|
|
55
|
+
if (!definition) {
|
|
56
|
+
throw Boom.badRequest(`Unknown entity type ${entityType}`, {
|
|
57
|
+
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ownership = this.authorizeEntityForUser.resolveOwnership({
|
|
62
|
+
user,
|
|
63
|
+
entityType,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const credential =
|
|
67
|
+
await this.credentialRepository.findCredentialById(credentialId);
|
|
68
|
+
// A missing credential and someone else's are the same answer, so ids
|
|
69
|
+
// cannot be probed.
|
|
70
|
+
if (!credential || !sameId(credential.userId, ownership.ownerId)) {
|
|
71
|
+
throw Boom.badRequest('Invalid credential ID', {
|
|
72
|
+
code: 'INVALID_CREDENTIAL',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Built for the owner, with the credential loaded (the repository
|
|
77
|
+
// returns its tokens flat; a Module reads them from `data`), so the
|
|
78
|
+
// API can call the provider and a token refresh during
|
|
79
|
+
// getEntityDetails updates the owner's credential.
|
|
80
|
+
const { id, userId, externalId, authIsValid, ...data } = credential;
|
|
81
|
+
const module = this.createModule({
|
|
82
|
+
userId: ownership.ownerId,
|
|
83
|
+
definition,
|
|
84
|
+
entity: {
|
|
85
|
+
credential: { id, userId, externalId, authIsValid, data },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const entityDetails =
|
|
89
|
+
await definition.requiredAuthMethods.getEntityDetails(
|
|
90
|
+
module.api,
|
|
91
|
+
null,
|
|
92
|
+
null,
|
|
93
|
+
ownership.ownerId
|
|
94
|
+
);
|
|
95
|
+
Object.assign(
|
|
96
|
+
entityDetails.details,
|
|
97
|
+
module.apiParamsFromEntity(module.api)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return this.processAuthorizationCallback.findOrCreateEntity(
|
|
101
|
+
entityDetails,
|
|
102
|
+
definition.moduleName,
|
|
103
|
+
credential.id,
|
|
104
|
+
{
|
|
105
|
+
scope: ownership.scope,
|
|
106
|
+
organizationId: ownership.organizationId,
|
|
107
|
+
legacyOrgId: ownership.legacyOrgId,
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { CreateEntityFromCredential };
|
|
@@ -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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
|
@@ -60,8 +60,11 @@ class GetEntityOptionsById {
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
64
|
+
// organization's entity, and a token refresh during this call must
|
|
65
|
+
// update the owner's credential, not create one under the caller.
|
|
63
66
|
const module = new Module({
|
|
64
|
-
userId,
|
|
67
|
+
userId: entity.userId ?? userId,
|
|
65
68
|
entity,
|
|
66
69
|
definition: moduleDefinition,
|
|
67
70
|
});
|
|
@@ -56,8 +56,11 @@ class GetModule {
|
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
60
|
+
// organization's entity, and a token refresh during this call must
|
|
61
|
+
// update the owner's credential, not create one under the caller.
|
|
59
62
|
const module = new Module({
|
|
60
|
-
userId,
|
|
63
|
+
userId: entity.userId ?? userId,
|
|
61
64
|
entity,
|
|
62
65
|
definition: moduleDefinition,
|
|
63
66
|
});
|
|
@@ -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 };
|
|
@@ -61,8 +61,11 @@ class RefreshEntityOptions {
|
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
65
|
+
// organization's entity, and a token refresh during this call must
|
|
66
|
+
// update the owner's credential, not create one under the caller.
|
|
64
67
|
const module = new Module({
|
|
65
|
-
userId,
|
|
68
|
+
userId: entity.userId ?? userId,
|
|
66
69
|
entity,
|
|
67
70
|
definition: moduleDefinition,
|
|
68
71
|
});
|
|
@@ -60,8 +60,11 @@ class TestModuleAuth {
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
64
|
+
// organization's entity, and a token refresh during this call must
|
|
65
|
+
// update the owner's credential, not create one under the caller.
|
|
63
66
|
const module = new Module({
|
|
64
|
-
userId,
|
|
67
|
+
userId: entity.userId ?? userId,
|
|
65
68
|
entity,
|
|
66
69
|
definition: moduleDefinition,
|
|
67
70
|
});
|
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.
|
|
4
|
+
"version": "2.0.0--canary.683.8a84ab1.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.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.683.8a84ab1.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.683.8a84ab1.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.683.8a84ab1.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": "
|
|
93
|
+
"gitHead": "8a84ab14e5e56c7e7939b1b6a0b656ed9301dcfd"
|
|
94
94
|
}
|
package/types/modules/index.d.ts
CHANGED
|
@@ -138,6 +138,16 @@ declare module "@friggframework/core" {
|
|
|
138
138
|
appDefinition?: Pick<AppDefinition, "entities" | "integrations">
|
|
139
139
|
): EntityTypeRegistry;
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* @deprecated ADR-059. Use `getEntityTypeRegistry(appDefinition)` and read
|
|
143
|
+
* each entry's `definition`. Returns the registry's definitions for these
|
|
144
|
+
* integrations (plus registry-only types when `entities` is passed).
|
|
145
|
+
*/
|
|
146
|
+
export function getModulesDefinitionFromIntegrationClasses(
|
|
147
|
+
integrationClasses: any[],
|
|
148
|
+
entities?: AppDefinitionEntities
|
|
149
|
+
): any[];
|
|
150
|
+
|
|
141
151
|
/** Invalid `entities` registry. `code` is always `ENTITY_REGISTRY_INVALID`. */
|
|
142
152
|
export class EntityRegistryError extends Error {
|
|
143
153
|
code: "ENTITY_REGISTRY_INVALID";
|
package/types/user/index.d.ts
CHANGED
|
@@ -62,6 +62,20 @@ declare module "@friggframework/core/user" {
|
|
|
62
62
|
* at startup. `strictMembership` wins when both are set.
|
|
63
63
|
*/
|
|
64
64
|
strictUserValidation?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Roles that may manage organization-level things (ADR-056 §3). Default
|
|
67
|
+
* `['ADMIN']`. The role is the adopter token's role claim, else the stored
|
|
68
|
+
* membership `orgRole`, else `MEMBER` (never admin). A shared-secret
|
|
69
|
+
* (`x-frigg` header) caller needs no role, nor does a Frigg bearer token
|
|
70
|
+
* unless `orgAdminGateBearerTokens`. Compared case-insensitively.
|
|
71
|
+
* Validated at load.
|
|
72
|
+
*/
|
|
73
|
+
orgAdminRoles?: string[];
|
|
74
|
+
/**
|
|
75
|
+
* Judge Frigg bearer-token callers by role like adopter-token callers.
|
|
76
|
+
* Default false: they keep their behaviour from before organization roles.
|
|
77
|
+
*/
|
|
78
|
+
orgAdminGateBearerTokens?: boolean;
|
|
65
79
|
authModes?: UserAuthModes;
|
|
66
80
|
jwtConfig?: AdopterJwtConfig;
|
|
67
81
|
}
|
|
@@ -113,5 +127,8 @@ declare module "@friggframework/core/user" {
|
|
|
113
127
|
/** The organization role from the adopter token for this request, or null. */
|
|
114
128
|
getOrgRole(): string | null;
|
|
115
129
|
setOrgRole(orgRole: string | null): void;
|
|
130
|
+
/** The stored `orgRole` of the membership of the active organization, or null. Used when the token carries no role. */
|
|
131
|
+
getMembershipRole(): string | null;
|
|
132
|
+
setMembershipRole(membershipRole: string | null): void;
|
|
116
133
|
}
|
|
117
134
|
}
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -74,8 +74,14 @@ class GetUserFromAdopterJwt {
|
|
|
74
74
|
const role = claims[roleClaim];
|
|
75
75
|
|
|
76
76
|
const orgRole = typeof role === 'string' && role !== '' ? role : null;
|
|
77
|
-
const user = await this._findOrCreateUser(
|
|
77
|
+
const { user, membership } = await this._findOrCreateUser(
|
|
78
|
+
appUserId,
|
|
79
|
+
appOrgId,
|
|
80
|
+
orgRole
|
|
81
|
+
);
|
|
78
82
|
user.setOrgRole(orgRole);
|
|
83
|
+
// What the membership row says: the role when the token carries none.
|
|
84
|
+
user.setMembershipRole(membership?.orgRole ?? null);
|
|
79
85
|
|
|
80
86
|
log.debug('Adopter token accepted', {
|
|
81
87
|
eventName: `${log.name}.accepted`,
|
|
@@ -98,12 +104,16 @@ class GetUserFromAdopterJwt {
|
|
|
98
104
|
|
|
99
105
|
// The token's organization is the active one whether or not this is
|
|
100
106
|
// the person's first time in it.
|
|
101
|
-
const { individual } =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
const { individual, membership } =
|
|
108
|
+
await this.ensureOrganizationMembership.execute({
|
|
109
|
+
individual: created.individual,
|
|
110
|
+
organization: created.organization,
|
|
111
|
+
orgRole,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
user: buildUser(cfg, individual, created.organization),
|
|
115
|
+
membership,
|
|
116
|
+
};
|
|
107
117
|
}
|
|
108
118
|
}
|
|
109
119
|
|
package/user/user.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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 Frigg bearer token keeps its pre-roles
|
|
8
|
+
* behaviour unless the app opts in (`user.orgAdminGateBearerTokens`); an
|
|
9
|
+
* adopter token has the role it carries, else the stored membership role,
|
|
10
|
+
* else `MEMBER`.
|
|
11
|
+
*/
|
|
12
|
+
const AUTH_SOURCES = Object.freeze({
|
|
13
|
+
SHARED_SECRET: 'shared-secret',
|
|
14
|
+
ADOPTER_JWT: 'adopter-jwt',
|
|
15
|
+
BEARER_TOKEN: 'bearer-token',
|
|
16
|
+
});
|
|
17
|
+
|
|
3
18
|
/**
|
|
4
19
|
* Represents a user in the system. The User class is a domain entity,
|
|
5
20
|
* @class User
|
|
@@ -18,9 +33,14 @@ class User {
|
|
|
18
33
|
this.individualUser = individualUser;
|
|
19
34
|
this.organizationUser = organizationUser;
|
|
20
35
|
this.usePassword = usePassword;
|
|
21
|
-
// Organization role from an adopter token claim (ADR-056 §2)
|
|
22
|
-
//
|
|
36
|
+
// Organization role from an adopter token claim (ADR-056 §2), per
|
|
37
|
+
// request. When present it wins over the stored membership role.
|
|
23
38
|
this.orgRole = null;
|
|
39
|
+
// The role stored on the person's membership of the active organization
|
|
40
|
+
// (OrganizationMembership.orgRole); used when the token carries none.
|
|
41
|
+
this.membershipRole = null;
|
|
42
|
+
// How this request authenticated (one of AUTH_SOURCES); null until AuthenticateUser says.
|
|
43
|
+
this.authSource = null;
|
|
24
44
|
|
|
25
45
|
this.config = {
|
|
26
46
|
primary,
|
|
@@ -84,6 +104,14 @@ class User {
|
|
|
84
104
|
return this.config.individualUserRequired;
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
getAuthSource() {
|
|
108
|
+
return this.authSource;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setAuthSource(authSource) {
|
|
112
|
+
this.authSource = authSource ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
getOrgRole() {
|
|
88
116
|
return this.orgRole;
|
|
89
117
|
}
|
|
@@ -92,6 +120,14 @@ class User {
|
|
|
92
120
|
this.orgRole = orgRole ?? null;
|
|
93
121
|
}
|
|
94
122
|
|
|
123
|
+
getMembershipRole() {
|
|
124
|
+
return this.membershipRole;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setMembershipRole(membershipRole) {
|
|
128
|
+
this.membershipRole = membershipRole ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
95
131
|
getIndividualUser() {
|
|
96
132
|
return this.individualUser;
|
|
97
133
|
}
|
|
@@ -149,4 +185,4 @@ class User {
|
|
|
149
185
|
}
|
|
150
186
|
}
|
|
151
187
|
|
|
152
|
-
module.exports = { User };
|
|
188
|
+
module.exports = { User, AUTH_SOURCES };
|