@friggframework/core 2.0.0--canary.684.9d19f43.0 → 2.0.0--canary.682.cbca9e2.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 +25 -110
- package/credential/use-cases/reauthorize-credential-for-user.js +8 -15
- package/integrations/integration-router.js +5 -7
- package/integrations/use-cases/integration-operations-for-user.js +6 -16
- package/management-api/openapi/components.js +0 -1
- package/management-api/openapi/v2-operations.js +0 -3
- package/management-api/v2/dependencies.js +0 -2
- package/management-api/v2/dto.js +0 -3
- package/management-api/v2/handlers/credentials.js +2 -6
- package/modules/scope-config.js +1 -23
- package/modules/scope-policy.js +3 -34
- package/modules/scope-resolution.js +0 -11
- package/modules/scope-visibility.js +8 -55
- package/modules/use-cases/authorize-entity-for-user.js +2 -4
- package/modules/use-cases/entity-operations-for-user.js +4 -9
- package/modules/use-cases/multi-step-authorization.js +1 -4
- package/package.json +5 -5
- package/types/user/index.d.ts +0 -7
- package/user/repositories/organization-membership-repository-documentdb.js +4 -2
- package/user/repositories/user-repository-documentdb.js +4 -2
- package/user/use-cases/authenticate-user.js +1 -7
- package/user/user.js +1 -24
- package/modules/use-cases/manage-access.js +0 -21
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). |
|
|
397
397
|
|
|
398
398
|
Rules worth knowing:
|
|
399
399
|
|
|
@@ -1,28 +1,10 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const {
|
|
3
|
-
createScopePolicy,
|
|
4
|
-
describeRecord,
|
|
5
|
-
visibilityClauses,
|
|
6
|
-
matchesListFilters,
|
|
7
|
-
SCOPES,
|
|
8
|
-
} = require('../../modules/entity-scope');
|
|
9
|
-
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
2
|
+
const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
|
|
10
3
|
|
|
11
4
|
/**
|
|
12
|
-
* Management API v2 credential operations
|
|
13
|
-
*
|
|
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.
|
|
5
|
+
* Management API v2 credential operations. A credential has no module name
|
|
6
|
+
* of its own; its `type` and the entities that use it come from the
|
|
7
|
+
* caller's entities. A missing or foreign credential is a 404.
|
|
26
8
|
*/
|
|
27
9
|
|
|
28
10
|
const credentialIdOf = (entity) => {
|
|
@@ -55,82 +37,31 @@ function withEntityUsage(credentials, entities) {
|
|
|
55
37
|
});
|
|
56
38
|
}
|
|
57
39
|
|
|
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
|
-
*/
|
|
40
|
+
/** The caller's credentials with their type and entity usage. */
|
|
88
41
|
class ListCredentialsForUser {
|
|
89
|
-
constructor({ credentialRepository, moduleRepository
|
|
42
|
+
constructor({ credentialRepository, moduleRepository }) {
|
|
90
43
|
this.credentialRepository = credentialRepository;
|
|
91
44
|
this.moduleRepository = moduleRepository;
|
|
92
|
-
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
93
45
|
}
|
|
94
46
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
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))),
|
|
47
|
+
async execute(user) {
|
|
48
|
+
const userId = user.getId();
|
|
49
|
+
const [credentials, entities] = await Promise.all([
|
|
50
|
+
this.credentialRepository.findCredentialsByUserId(userId),
|
|
51
|
+
this.moduleRepository.findEntitiesByUserId(userId),
|
|
105
52
|
]);
|
|
106
|
-
|
|
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;
|
|
53
|
+
return withEntityUsage(credentials || [], entities);
|
|
118
54
|
}
|
|
119
55
|
}
|
|
120
56
|
|
|
121
|
-
/** One credential the caller
|
|
57
|
+
/** One credential the caller owns, with its type and entity usage. */
|
|
122
58
|
class GetCredentialForUserV2 {
|
|
123
|
-
constructor({ credentialRepository, moduleRepository
|
|
59
|
+
constructor({ credentialRepository, moduleRepository }) {
|
|
124
60
|
this.credentialRepository = credentialRepository;
|
|
125
61
|
this.moduleRepository = moduleRepository;
|
|
126
|
-
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
127
62
|
}
|
|
128
63
|
|
|
129
64
|
async execute(credentialId, user) {
|
|
130
|
-
const notFound = () =>
|
|
131
|
-
Boom.notFound(`Credential ${credentialId} not found`, {
|
|
132
|
-
code: 'CREDENTIAL_NOT_FOUND',
|
|
133
|
-
});
|
|
134
65
|
let credential = null;
|
|
135
66
|
try {
|
|
136
67
|
credential = await this.credentialRepository.findCredentialById(
|
|
@@ -141,29 +72,22 @@ class GetCredentialForUserV2 {
|
|
|
141
72
|
throw error;
|
|
142
73
|
}
|
|
143
74
|
}
|
|
144
|
-
if (!credential
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return { ...withEntityUsage([credential], visible)[0], tenancy };
|
|
75
|
+
if (!credential || !userOwns(user, credential.userId)) {
|
|
76
|
+
throw Boom.notFound(`Credential ${credentialId} not found`, {
|
|
77
|
+
code: 'CREDENTIAL_NOT_FOUND',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const entities = await this.moduleRepository.findEntitiesByUserId(
|
|
81
|
+
credential.userId
|
|
82
|
+
);
|
|
83
|
+
return withEntityUsage([credential], entities)[0];
|
|
154
84
|
}
|
|
155
85
|
}
|
|
156
86
|
|
|
157
87
|
/**
|
|
158
|
-
* Deletes a credential the caller
|
|
159
|
-
*
|
|
88
|
+
* Deletes a credential the caller owns. The entities that used it stay, with
|
|
89
|
+
* their credential unset, so they show as needing re-authorization rather
|
|
160
90
|
* 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
91
|
*/
|
|
168
92
|
class DeleteCredentialForUser {
|
|
169
93
|
constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
|
|
@@ -177,15 +101,6 @@ class DeleteCredentialForUser {
|
|
|
177
101
|
credentialId,
|
|
178
102
|
user
|
|
179
103
|
);
|
|
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
|
-
}
|
|
189
104
|
for (const entityId of credential.entityIds) {
|
|
190
105
|
await this.moduleRepository.unsetCredential(entityId);
|
|
191
106
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Re-authorization of a credential the caller owns (Management API v2). It
|
|
@@ -15,13 +14,7 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
|
|
|
15
14
|
{ code: 'CREDENTIAL_TYPE_UNKNOWN' }
|
|
16
15
|
);
|
|
17
16
|
}
|
|
18
|
-
return {
|
|
19
|
-
entityType: credential.type,
|
|
20
|
-
credentialId: String(credential.id),
|
|
21
|
-
// A re-authorization keeps the scope the connection already has.
|
|
22
|
-
fixedScope: credential.tenancy?.scope,
|
|
23
|
-
access: credential.tenancy?.access,
|
|
24
|
-
};
|
|
17
|
+
return { entityType: credential.type, credentialId: String(credential.id) };
|
|
25
18
|
}
|
|
26
19
|
|
|
27
20
|
/** GET /api/v2/credentials/:credentialId/reauthorize */
|
|
@@ -32,12 +25,14 @@ class GetCredentialReauthorization {
|
|
|
32
25
|
}
|
|
33
26
|
|
|
34
27
|
async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
|
|
35
|
-
const
|
|
36
|
-
|
|
28
|
+
const binding = await credentialEntityType(
|
|
29
|
+
this.getCredentialForUser,
|
|
30
|
+
credentialId,
|
|
31
|
+
user
|
|
32
|
+
);
|
|
37
33
|
return this.getAuthorizationStep.execute({
|
|
38
34
|
user,
|
|
39
|
-
|
|
40
|
-
credentialId: boundCredentialId,
|
|
35
|
+
...binding,
|
|
41
36
|
step,
|
|
42
37
|
sessionId,
|
|
43
38
|
state,
|
|
@@ -53,13 +48,11 @@ class ReauthorizeCredentialForUser {
|
|
|
53
48
|
}
|
|
54
49
|
|
|
55
50
|
async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
|
|
56
|
-
const
|
|
51
|
+
const binding = await credentialEntityType(
|
|
57
52
|
this.getCredentialForUser,
|
|
58
53
|
credentialId,
|
|
59
54
|
user
|
|
60
55
|
);
|
|
61
|
-
// A credential an organization owns: the organization's admins only.
|
|
62
|
-
assertCanManage(access, `credential ${credentialId}`);
|
|
63
56
|
return this.submitAuthorizationStep.execute({
|
|
64
57
|
user,
|
|
65
58
|
...binding,
|
|
@@ -279,11 +279,9 @@ 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
|
-
|
|
284
|
-
|
|
285
|
-
(await getOwnedIntegrationRecord.execute(integrationId, user, options))
|
|
286
|
-
.userId;
|
|
282
|
+
// what the id-and-owner use cases below expect.
|
|
283
|
+
const ownerOf = async (integrationId, user) =>
|
|
284
|
+
(await getOwnedIntegrationRecord.execute(integrationId, user)).userId;
|
|
287
285
|
|
|
288
286
|
router.route('/api/integrations').get(
|
|
289
287
|
catchAsyncError(async (req, res) => {
|
|
@@ -335,7 +333,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
335
333
|
|
|
336
334
|
const integration = await updateIntegration.execute(
|
|
337
335
|
req.params.integrationId,
|
|
338
|
-
await ownerOf(req.params.integrationId, user
|
|
336
|
+
await ownerOf(req.params.integrationId, user),
|
|
339
337
|
params.config
|
|
340
338
|
);
|
|
341
339
|
res.json(integration);
|
|
@@ -348,7 +346,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
348
346
|
const params = checkRequiredParams(req.params, ['integrationId']);
|
|
349
347
|
await deleteIntegrationForUser.execute(
|
|
350
348
|
params.integrationId,
|
|
351
|
-
await ownerOf(params.integrationId, user
|
|
349
|
+
await ownerOf(params.integrationId, user)
|
|
352
350
|
);
|
|
353
351
|
res.status(204).json({});
|
|
354
352
|
})
|
|
@@ -1,7 +1,6 @@
|
|
|
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');
|
|
5
4
|
const { isIntegrationNotFound } = require('../repositories/integration-not-found');
|
|
6
5
|
|
|
7
6
|
/**
|
|
@@ -33,14 +32,7 @@ class GetOwnedIntegrationRecord {
|
|
|
33
32
|
return describeRecord(record, this.scopePolicy.accessContext(user), 'integration');
|
|
34
33
|
}
|
|
35
34
|
|
|
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 } = {}) {
|
|
35
|
+
async execute(integrationId, user) {
|
|
44
36
|
let record = null;
|
|
45
37
|
try {
|
|
46
38
|
record = await this.integrationRepository.findIntegrationById(
|
|
@@ -55,9 +47,9 @@ class GetOwnedIntegrationRecord {
|
|
|
55
47
|
}
|
|
56
48
|
throw error;
|
|
57
49
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
if (!record || !this.describe(record, user)) {
|
|
51
|
+
throw notFound(integrationId);
|
|
52
|
+
}
|
|
61
53
|
return record;
|
|
62
54
|
}
|
|
63
55
|
}
|
|
@@ -146,8 +138,7 @@ class UpdateIntegrationForUser {
|
|
|
146
138
|
}
|
|
147
139
|
const record = await this.getOwnedIntegrationRecord.execute(
|
|
148
140
|
integrationId,
|
|
149
|
-
user
|
|
150
|
-
{ manage: true }
|
|
141
|
+
user
|
|
151
142
|
);
|
|
152
143
|
return this.updateIntegration.execute(record.id, record.userId, config);
|
|
153
144
|
}
|
|
@@ -163,8 +154,7 @@ class RemoveIntegrationForUser {
|
|
|
163
154
|
async execute(integrationId, user) {
|
|
164
155
|
const record = await this.getOwnedIntegrationRecord.execute(
|
|
165
156
|
integrationId,
|
|
166
|
-
user
|
|
167
|
-
{ manage: true }
|
|
157
|
+
user
|
|
168
158
|
);
|
|
169
159
|
await this.deleteIntegrationForUser.execute(record.id, record.userId);
|
|
170
160
|
}
|
|
@@ -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,7 +246,6 @@ 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({
|
package/management-api/v2/dto.js
CHANGED
|
@@ -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(
|
|
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
|
|
package/modules/scope-config.js
CHANGED
|
@@ -8,7 +8,6 @@ const {
|
|
|
8
8
|
SCOPES,
|
|
9
9
|
INDIVIDUAL_ACROSS_VALUES,
|
|
10
10
|
OWNER_RULES,
|
|
11
|
-
DEFAULT_ORG_ADMIN_ROLES,
|
|
12
11
|
configError,
|
|
13
12
|
isPlainObject,
|
|
14
13
|
parseScopeSetting,
|
|
@@ -76,26 +75,6 @@ function readIntegrationOwnership(integrationClass) {
|
|
|
76
75
|
return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner };
|
|
77
76
|
}
|
|
78
77
|
|
|
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
78
|
/**
|
|
100
79
|
* Every binding of every integration with its resolved scope and where it came
|
|
101
80
|
* from, parsing each integration's settings once. Throws OwnershipConfigError
|
|
@@ -139,7 +118,7 @@ function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
|
|
|
139
118
|
* Validates every ownership setting in one pass and returns the app-level
|
|
140
119
|
* config the runtime needs.
|
|
141
120
|
*
|
|
142
|
-
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string
|
|
121
|
+
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string}}
|
|
143
122
|
* @throws {OwnershipConfigError}
|
|
144
123
|
*/
|
|
145
124
|
function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
@@ -164,7 +143,6 @@ function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
|
164
143
|
return Object.freeze({
|
|
165
144
|
scope: app.scope,
|
|
166
145
|
individualAcross: app.individualAcross,
|
|
167
|
-
orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
|
|
168
146
|
});
|
|
169
147
|
}
|
|
170
148
|
|
package/modules/scope-policy.js
CHANGED
|
@@ -6,21 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
const {
|
|
8
8
|
INDIVIDUAL_ACROSS_VALUES,
|
|
9
|
-
DEFAULT_ORG_ADMIN_ROLES,
|
|
10
9
|
configError,
|
|
11
10
|
parseScopeSetting,
|
|
12
11
|
resolveScope,
|
|
13
12
|
resolveOwnerForScope,
|
|
14
13
|
} = require('./scope-resolution');
|
|
15
|
-
const {
|
|
16
|
-
accessContext,
|
|
17
|
-
accessToOwner,
|
|
18
|
-
describeRecord,
|
|
19
|
-
} = require('./scope-visibility');
|
|
14
|
+
const { accessContext, describeRecord } = require('./scope-visibility');
|
|
20
15
|
|
|
21
16
|
/**
|
|
22
17
|
* @param {Object} [params]
|
|
23
|
-
* @param {{scope: *, individualAcross: string
|
|
18
|
+
* @param {{scope: *, individualAcross: string}} [params.ownership] - the raw
|
|
24
19
|
* `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
|
|
25
20
|
* `{ default, allow }` parses to itself)
|
|
26
21
|
* @param {Map} [params.entityTypeRegistry]
|
|
@@ -38,10 +33,7 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
38
33
|
);
|
|
39
34
|
}
|
|
40
35
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const contextFor = (user) =>
|
|
44
|
-
accessContext(user, { individualAcross, orgAdminRoles });
|
|
36
|
+
const contextFor = (user) => accessContext(user, { individualAcross });
|
|
45
37
|
|
|
46
38
|
/** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
|
|
47
39
|
const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
|
|
@@ -69,38 +61,15 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
69
61
|
};
|
|
70
62
|
};
|
|
71
63
|
|
|
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
64
|
/** Whether `user` may load this stored entity for use (same clauses as the list). */
|
|
87
65
|
const canUseEntity = (user, entity) =>
|
|
88
66
|
describeRecord(entity, contextFor(user), 'entity') !== null;
|
|
89
67
|
|
|
90
|
-
/**
|
|
91
|
-
* What `user` may do with a record owned by `ownerId` that has no
|
|
92
|
-
* organization of its own (a credential): manage, or null when not theirs.
|
|
93
|
-
*/
|
|
94
|
-
const accessToOwnedBy = (user, ownerId) => accessToOwner(ownerId, contextFor(user));
|
|
95
|
-
|
|
96
68
|
return Object.freeze({
|
|
97
69
|
individualAcross,
|
|
98
|
-
orgAdminRoles,
|
|
99
|
-
accessToOwnedBy,
|
|
100
70
|
accessContext: contextFor,
|
|
101
71
|
resolveScope: resolveEntityScope,
|
|
102
72
|
resolveOwnership,
|
|
103
|
-
ownershipForScope,
|
|
104
73
|
canUseEntity,
|
|
105
74
|
});
|
|
106
75
|
}
|
|
@@ -25,9 +25,6 @@ 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
|
-
|
|
31
28
|
/** Raised at app-definition load for an invalid ownership setting. */
|
|
32
29
|
class OwnershipConfigError extends BaseError {
|
|
33
30
|
constructor(message, options) {
|
|
@@ -183,11 +180,6 @@ const MISSING_ORGANIZATION =
|
|
|
183
180
|
const missing = (message, field) =>
|
|
184
181
|
Boom.badRequest(message, { code: 'IDENTITY_REQUIRED', details: { field } });
|
|
185
182
|
|
|
186
|
-
const adminRequired = (what) =>
|
|
187
|
-
Boom.forbidden(`only an organization admin can ${what}`, {
|
|
188
|
-
code: 'ORG_ADMIN_REQUIRED',
|
|
189
|
-
});
|
|
190
|
-
|
|
191
183
|
/**
|
|
192
184
|
* The owner and organization for a new entity of `scope` (§3.2):
|
|
193
185
|
*
|
|
@@ -208,7 +200,6 @@ function resolveOwnerForScope(scope, ctx) {
|
|
|
208
200
|
}
|
|
209
201
|
if (scope === SCOPES.ORGANIZATION) {
|
|
210
202
|
if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid');
|
|
211
|
-
if (!ctx.canManageOrg) throw adminRequired('connect an organization-level account');
|
|
212
203
|
return { scope, ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId };
|
|
213
204
|
}
|
|
214
205
|
throw Boom.forbidden(
|
|
@@ -238,7 +229,6 @@ function integrationOwnerKind(entityScopes, rule = 'derive') {
|
|
|
238
229
|
function resolveIntegrationOwner(kind, ctx) {
|
|
239
230
|
if (kind === SCOPES.ORGANIZATION) {
|
|
240
231
|
if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid');
|
|
241
|
-
if (!ctx.canManageOrg) throw adminRequired('create an organization-level integration');
|
|
242
232
|
return { ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId };
|
|
243
233
|
}
|
|
244
234
|
if (!ctx.callerId) throw missing(MISSING_INDIVIDUAL, 'x-frigg-appuserid');
|
|
@@ -273,7 +263,6 @@ module.exports = {
|
|
|
273
263
|
INDIVIDUAL_ACROSS_VALUES,
|
|
274
264
|
OWNER_RULES,
|
|
275
265
|
ACCESS,
|
|
276
|
-
DEFAULT_ORG_ADMIN_ROLES,
|
|
277
266
|
OwnershipConfigError,
|
|
278
267
|
configError,
|
|
279
268
|
isPlainObject,
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const { isPresent, idString, sameId } = require('../user/membership');
|
|
11
|
-
const {
|
|
12
|
-
const { SCOPES, ACCESS, DEFAULT_ORG_ADMIN_ROLES } = require('./scope-resolution');
|
|
11
|
+
const { SCOPES, ACCESS } = require('./scope-resolution');
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* One owner/organization pair a repository ORs into a single query.
|
|
@@ -67,35 +66,15 @@ function buildClauses(base, kind) {
|
|
|
67
66
|
return clauses;
|
|
68
67
|
}
|
|
69
68
|
|
|
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
|
-
|
|
86
69
|
/**
|
|
87
70
|
* Who the caller is, for ownership and visibility: their individual user, the
|
|
88
|
-
* organization this request acts in, the legacy `organizationId` pointer,
|
|
89
|
-
*
|
|
90
|
-
* follow from them (computed once).
|
|
71
|
+
* organization this request acts in, the legacy `organizationId` pointer, and
|
|
72
|
+
* the clauses that follow from them (computed once).
|
|
91
73
|
*
|
|
92
74
|
* @param {Object} user - the authenticated `User` (or a `{ getId }` double)
|
|
93
|
-
* @param {{individualAcross?: string
|
|
75
|
+
* @param {{individualAcross?: string}} [options]
|
|
94
76
|
*/
|
|
95
|
-
function accessContext(
|
|
96
|
-
user,
|
|
97
|
-
{ individualAcross = 'organization', orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES } = {}
|
|
98
|
-
) {
|
|
77
|
+
function accessContext(user, { individualAcross = 'organization' } = {}) {
|
|
99
78
|
const hasUserModel = typeof user?.getIndividualUser === 'function';
|
|
100
79
|
const individual = hasUserModel ? user.getIndividualUser() : null;
|
|
101
80
|
const base = {
|
|
@@ -107,14 +86,8 @@ function accessContext(
|
|
|
107
86
|
legacyOrgId: idString(individual?.organizationId),
|
|
108
87
|
individualAcross,
|
|
109
88
|
};
|
|
110
|
-
const who = {
|
|
111
|
-
authSource:
|
|
112
|
-
typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
|
|
113
|
-
orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
|
|
114
|
-
};
|
|
115
89
|
return {
|
|
116
90
|
...base,
|
|
117
|
-
canManageOrg: canManageOrganization(who, orgAdminRoles),
|
|
118
91
|
clauses: {
|
|
119
92
|
entity: buildClauses(base, 'entity'),
|
|
120
93
|
integration: buildClauses(base, 'integration'),
|
|
@@ -139,26 +112,8 @@ function scopeOf(record, ctx) {
|
|
|
139
112
|
return null;
|
|
140
113
|
}
|
|
141
114
|
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
}
|
|
115
|
+
const accessFor = (scope) =>
|
|
116
|
+
scope === SCOPES.GLOBAL ? ACCESS.USE : ACCESS.MANAGE;
|
|
162
117
|
|
|
163
118
|
/**
|
|
164
119
|
* What a row is to this caller, or null when they may not see it.
|
|
@@ -179,7 +134,7 @@ function describeRecord(record, ctx, kind) {
|
|
|
179
134
|
scope === SCOPES.ORGANIZATION
|
|
180
135
|
? idString(record.userId)
|
|
181
136
|
: idString(record.organizationId),
|
|
182
|
-
access: accessFor(scope
|
|
137
|
+
access: accessFor(scope),
|
|
183
138
|
};
|
|
184
139
|
}
|
|
185
140
|
|
|
@@ -199,8 +154,6 @@ function matchesListFilters(description, { scope, owner } = {}) {
|
|
|
199
154
|
}
|
|
200
155
|
|
|
201
156
|
module.exports = {
|
|
202
|
-
canManageOrganization,
|
|
203
|
-
accessToOwner,
|
|
204
157
|
accessContext,
|
|
205
158
|
unscopedRowsBelongHere,
|
|
206
159
|
visibilityClauses,
|
|
@@ -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
|
|
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
|
|
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,
|
|
@@ -5,7 +5,6 @@ const {
|
|
|
5
5
|
matchesListFilters,
|
|
6
6
|
} = require('../scope-visibility');
|
|
7
7
|
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
8
|
-
const { assertCanManage } = require('./manage-access');
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Management API v2 entity operations. Each one starts from
|
|
@@ -112,10 +111,10 @@ class RefreshEntityOptionsForUser {
|
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
/**
|
|
115
|
-
* Deletes an entity the caller may
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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.
|
|
119
118
|
*/
|
|
120
119
|
class DeleteEntityForUser {
|
|
121
120
|
constructor({ getEntityModuleForUser, moduleRepository, integrationRepository }) {
|
|
@@ -129,10 +128,6 @@ class DeleteEntityForUser {
|
|
|
129
128
|
entityId,
|
|
130
129
|
user
|
|
131
130
|
);
|
|
132
|
-
assertCanManage(
|
|
133
|
-
this.getEntityModuleForUser.describe(entity, user)?.access,
|
|
134
|
-
`entity ${entity.id}`
|
|
135
|
-
);
|
|
136
131
|
const integrations =
|
|
137
132
|
(await this.integrationRepository.findIntegrationsByEntityId(
|
|
138
133
|
entity.id
|
|
@@ -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
|
|
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.
|
|
4
|
+
"version": "2.0.0--canary.682.cbca9e2.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.682.cbca9e2.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.682.cbca9e2.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.682.cbca9e2.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": "cbca9e22cf6af8765f83f03b2e6d3700d1e149b5"
|
|
94
94
|
}
|
package/types/user/index.d.ts
CHANGED
|
@@ -62,13 +62,6 @@ 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[];
|
|
72
65
|
authModes?: UserAuthModes;
|
|
73
66
|
jwtConfig?: AdopterJwtConfig;
|
|
74
67
|
}
|
|
@@ -3,7 +3,7 @@ const {
|
|
|
3
3
|
toObjectId,
|
|
4
4
|
fromObjectId,
|
|
5
5
|
findOne,
|
|
6
|
-
|
|
6
|
+
findManyDrained,
|
|
7
7
|
updateOne,
|
|
8
8
|
} = require('../../database/documentdb-utils');
|
|
9
9
|
const { DUPLICATE_KEY_CODE } = require('../../database/unique-violation');
|
|
@@ -66,7 +66,9 @@ class OrganizationMembershipRepositoryDocumentDB extends OrganizationMembershipR
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
async listMembershipsForOrganization(organizationUserId) {
|
|
69
|
-
|
|
69
|
+
// Drained: findMany returns only the first cursor batch (~101 documents),
|
|
70
|
+
// which would silently drop members of a large organization.
|
|
71
|
+
const docs = await findManyDrained(
|
|
70
72
|
this.prisma,
|
|
71
73
|
COLLECTION,
|
|
72
74
|
{ organizationUserId: toObjectId(organizationUserId) },
|
|
@@ -4,7 +4,7 @@ const {
|
|
|
4
4
|
toObjectId,
|
|
5
5
|
fromObjectId,
|
|
6
6
|
findOne,
|
|
7
|
-
|
|
7
|
+
findManyDrained,
|
|
8
8
|
insertOne,
|
|
9
9
|
updateOne,
|
|
10
10
|
deleteOne,
|
|
@@ -72,7 +72,9 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
async findIndividualUsersByOrganizationId(organizationUserId) {
|
|
75
|
-
|
|
75
|
+
// Drained: findMany returns only the first cursor batch, which would
|
|
76
|
+
// drop people from a large organization.
|
|
77
|
+
const docs = await findManyDrained(this.prisma, 'User', {
|
|
76
78
|
organizationId: toObjectId(organizationUserId),
|
|
77
79
|
type: 'INDIVIDUAL',
|
|
78
80
|
});
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { getLogger } = require('../../logs');
|
|
3
|
-
const { AUTH_SOURCES } = require('../user');
|
|
4
3
|
|
|
5
4
|
const log = getLogger('frigg.user.authentication');
|
|
6
5
|
|
|
@@ -69,13 +68,10 @@ class AuthenticateUser {
|
|
|
69
68
|
// Validate the API key (authentication)
|
|
70
69
|
await this.authenticateWithSharedSecret.execute(apiKey);
|
|
71
70
|
// Get user from x-frigg headers (authorization)
|
|
72
|
-
|
|
71
|
+
return await this.getUserFromXFriggHeaders.execute(
|
|
73
72
|
appUserId,
|
|
74
73
|
appOrgId
|
|
75
74
|
);
|
|
76
|
-
// The app's own backend: trusted to manage organization things.
|
|
77
|
-
user.setAuthSource(AUTH_SOURCES.SHARED_SECRET);
|
|
78
|
-
return user;
|
|
79
75
|
}
|
|
80
76
|
log.debug('No x-frigg-api-key header, skipping shared secret', {
|
|
81
77
|
eventName: `${log.name}.shared_secret_skipped`,
|
|
@@ -94,7 +90,6 @@ class AuthenticateUser {
|
|
|
94
90
|
// Detect JWT format (3 parts separated by dots)
|
|
95
91
|
if (token && token.split('.').length === 3) {
|
|
96
92
|
const user = await this.getUserFromAdopterJwt.execute(token);
|
|
97
|
-
user.setAuthSource(AUTH_SOURCES.ADOPTER_JWT);
|
|
98
93
|
// Validate x-frigg headers match JWT claims if present
|
|
99
94
|
if (appUserId || appOrgId) {
|
|
100
95
|
this.validateUserMatch(user, appUserId, appOrgId);
|
|
@@ -111,7 +106,6 @@ class AuthenticateUser {
|
|
|
111
106
|
const user = await this.getUserFromBearerToken.execute(
|
|
112
107
|
req.headers.authorization
|
|
113
108
|
);
|
|
114
|
-
user.setAuthSource(AUTH_SOURCES.BEARER_TOKEN);
|
|
115
109
|
// Validate x-frigg headers match token user if present
|
|
116
110
|
if (appUserId || appOrgId) {
|
|
117
111
|
this.validateUserMatch(user, appUserId, appOrgId);
|
package/user/user.js
CHANGED
|
@@ -1,18 +1,5 @@
|
|
|
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
|
-
|
|
16
3
|
/**
|
|
17
4
|
* Represents a user in the system. The User class is a domain entity,
|
|
18
5
|
* @class User
|
|
@@ -34,8 +21,6 @@ class User {
|
|
|
34
21
|
// Organization role from an adopter token claim (ADR-056 §2). Per request:
|
|
35
22
|
// the role stored on a membership is only what the first token carried.
|
|
36
23
|
this.orgRole = null;
|
|
37
|
-
// How this request authenticated (one of AUTH_SOURCES); null until AuthenticateUser says.
|
|
38
|
-
this.authSource = null;
|
|
39
24
|
|
|
40
25
|
this.config = {
|
|
41
26
|
primary,
|
|
@@ -99,14 +84,6 @@ class User {
|
|
|
99
84
|
return this.config.individualUserRequired;
|
|
100
85
|
}
|
|
101
86
|
|
|
102
|
-
getAuthSource() {
|
|
103
|
-
return this.authSource;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
setAuthSource(authSource) {
|
|
107
|
-
this.authSource = authSource ?? null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
87
|
getOrgRole() {
|
|
111
88
|
return this.orgRole;
|
|
112
89
|
}
|
|
@@ -172,4 +149,4 @@ class User {
|
|
|
172
149
|
}
|
|
173
150
|
}
|
|
174
151
|
|
|
175
|
-
module.exports = { User
|
|
152
|
+
module.exports = { User };
|
|
@@ -1,21 +0,0 @@
|
|
|
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 };
|