@friggframework/core 2.0.0--canary.682.cbca9e2.0 → 2.0.0--canary.684.e113124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/credential/use-cases/credential-operations-for-user.js +110 -25
- package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
- package/integrations/integration-router.js +7 -5
- package/integrations/use-cases/integration-operations-for-user.js +16 -6
- package/management-api/openapi/components.js +1 -0
- package/management-api/openapi/v2-operations.js +3 -0
- package/management-api/v2/dependencies.js +2 -0
- package/management-api/v2/dto.js +3 -0
- package/management-api/v2/handlers/credentials.js +6 -2
- package/modules/scope-config.js +23 -1
- package/modules/scope-policy.js +34 -3
- package/modules/scope-resolution.js +11 -0
- package/modules/scope-visibility.js +55 -8
- package/modules/use-cases/authorize-entity-for-user.js +4 -2
- package/modules/use-cases/entity-operations-for-user.js +9 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +4 -1
- package/package.json +5 -5
- package/types/user/index.d.ts +7 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/user.js +24 -1
package/README.md
CHANGED
|
@@ -393,7 +393,7 @@ value that is not a three-part JWT is not offered to the verifier at all; it fal
|
|
|
393
393
|
| `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
|
|
394
394
|
| `userIdClaim` | `sub` | App user id (required in the token). |
|
|
395
395
|
| `orgIdClaim` | `org_id` | App organization id (optional). |
|
|
396
|
-
| `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). |
|
|
396
|
+
| `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). A token with no role is a member for permissions too: it cannot create or change organization-level entities or integrations; `user.orgAdminRoles` (default `['ADMIN']`) names the roles that can. |
|
|
397
397
|
|
|
398
398
|
Rules worth knowing:
|
|
399
399
|
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const {
|
|
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');
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
|
-
* Management API v2 credential operations. A credential
|
|
6
|
-
*
|
|
7
|
-
*
|
|
12
|
+
* Management API v2 credential operations (ADR-056 §3.4, §3.6). A credential
|
|
13
|
+
* has no module name and no organization of its own:
|
|
14
|
+
*
|
|
15
|
+
* - its `type` and `entityIds` come from the entities that use it that the
|
|
16
|
+
* caller may see, so the same person's account connected in two
|
|
17
|
+
* organizations (one credential, two entities) lists in each organization
|
|
18
|
+
* with that organization's entity only;
|
|
19
|
+
* - who it belongs to is its OWNER (`credential.userId`), never the entities:
|
|
20
|
+
* a credential an individual entity and an organization entity both point at
|
|
21
|
+
* (possible only through legacy data) is described by whoever owns the
|
|
22
|
+
* credential, so the answer cannot change with the order the entities load;
|
|
23
|
+
* - a credential whose entities are all out of sight (another organization's)
|
|
24
|
+
* is a 404, and one with no entities at all (its entity was deleted) is
|
|
25
|
+
* visible to its owner only, as it was before scoping.
|
|
8
26
|
*/
|
|
9
27
|
|
|
10
28
|
const credentialIdOf = (entity) => {
|
|
@@ -37,31 +55,82 @@ function withEntityUsage(credentials, entities) {
|
|
|
37
55
|
});
|
|
38
56
|
}
|
|
39
57
|
|
|
40
|
-
/**
|
|
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
|
+
*/
|
|
41
88
|
class ListCredentialsForUser {
|
|
42
|
-
constructor({ credentialRepository, moduleRepository }) {
|
|
89
|
+
constructor({ credentialRepository, moduleRepository, scopePolicy }) {
|
|
43
90
|
this.credentialRepository = credentialRepository;
|
|
44
91
|
this.moduleRepository = moduleRepository;
|
|
92
|
+
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
45
93
|
}
|
|
46
94
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
95
|
+
/** @param {{scope?: string, owner?: string}} [filters] */
|
|
96
|
+
async execute(user, filters = {}) {
|
|
97
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
98
|
+
const owners = [...new Set([ctx.callerId, ctx.activeOrgId].filter(Boolean))];
|
|
99
|
+
const [found, perOwner, owned] = await Promise.all([
|
|
100
|
+
this.moduleRepository.findEntitiesByOwnership(visibilityClauses(ctx, 'entity')),
|
|
101
|
+
Promise.all(owners.map((id) => this.credentialRepository.findCredentialsByUserId(id))),
|
|
102
|
+
// Every entity of those owners, in any organization: only to tell a credential
|
|
103
|
+
// with no entity at all from one used elsewhere.
|
|
104
|
+
Promise.all(owners.map((id) => this.moduleRepository.findEntitiesByUserId(id))),
|
|
52
105
|
]);
|
|
53
|
-
|
|
106
|
+
const visible = visibleOf(found, ctx);
|
|
107
|
+
const usedAnywhere = new Set(owned.flat().map(credentialIdOf).filter(Boolean));
|
|
108
|
+
const listed = [];
|
|
109
|
+
for (const credential of perOwner.flat().filter(Boolean)) {
|
|
110
|
+
const id = String(credential.id);
|
|
111
|
+
const referencing = visible.filter((entity) => credentialIdOf(entity) === id);
|
|
112
|
+
if (referencing.length === 0 && usedAnywhere.has(id)) continue;
|
|
113
|
+
const tenancy = credentialTenancy(credential, referencing, ctx);
|
|
114
|
+
if (!tenancy || !matchesListFilters(tenancy, filters)) continue;
|
|
115
|
+
listed.push({ ...withEntityUsage([credential], referencing)[0], tenancy });
|
|
116
|
+
}
|
|
117
|
+
return listed;
|
|
54
118
|
}
|
|
55
119
|
}
|
|
56
120
|
|
|
57
|
-
/** One credential the caller
|
|
121
|
+
/** One credential the caller may see: see the header. 404 otherwise. */
|
|
58
122
|
class GetCredentialForUserV2 {
|
|
59
|
-
constructor({ credentialRepository, moduleRepository }) {
|
|
123
|
+
constructor({ credentialRepository, moduleRepository, scopePolicy }) {
|
|
60
124
|
this.credentialRepository = credentialRepository;
|
|
61
125
|
this.moduleRepository = moduleRepository;
|
|
126
|
+
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
62
127
|
}
|
|
63
128
|
|
|
64
129
|
async execute(credentialId, user) {
|
|
130
|
+
const notFound = () =>
|
|
131
|
+
Boom.notFound(`Credential ${credentialId} not found`, {
|
|
132
|
+
code: 'CREDENTIAL_NOT_FOUND',
|
|
133
|
+
});
|
|
65
134
|
let credential = null;
|
|
66
135
|
try {
|
|
67
136
|
credential = await this.credentialRepository.findCredentialById(
|
|
@@ -72,22 +141,29 @@ class GetCredentialForUserV2 {
|
|
|
72
141
|
throw error;
|
|
73
142
|
}
|
|
74
143
|
}
|
|
75
|
-
if (!credential
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
|
|
144
|
+
if (!credential) throw notFound();
|
|
145
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
146
|
+
|
|
147
|
+
const referencing =
|
|
148
|
+
(await this.moduleRepository.findEntities({ credentialId: credential.id })) || [];
|
|
149
|
+
const visible = visibleOf(referencing, ctx);
|
|
150
|
+
if (referencing.length > 0 && visible.length === 0) throw notFound();
|
|
151
|
+
const tenancy = credentialTenancy(credential, visible, ctx);
|
|
152
|
+
if (!tenancy) throw notFound();
|
|
153
|
+
return { ...withEntityUsage([credential], visible)[0], tenancy };
|
|
84
154
|
}
|
|
85
155
|
}
|
|
86
156
|
|
|
87
157
|
/**
|
|
88
|
-
* Deletes a credential the caller
|
|
89
|
-
* their credential unset, so they show as needing re-authorization rather
|
|
158
|
+
* Deletes a credential the caller may manage. The entities that used it stay,
|
|
159
|
+
* with their credential unset, so they show as needing re-authorization rather
|
|
90
160
|
* than disappearing from integrations.
|
|
161
|
+
*
|
|
162
|
+
* Refuses with 409 `CREDENTIAL_SHARED` while an entity the caller cannot see
|
|
163
|
+
* still uses it: one credential row serves the same provider account in every
|
|
164
|
+
* organization, and deleting it from one would break the other. The message
|
|
165
|
+
* says nothing about the other connections (not even how many): remove the
|
|
166
|
+
* entities that use it, and it goes with the last one.
|
|
91
167
|
*/
|
|
92
168
|
class DeleteCredentialForUser {
|
|
93
169
|
constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
|
|
@@ -101,6 +177,15 @@ class DeleteCredentialForUser {
|
|
|
101
177
|
credentialId,
|
|
102
178
|
user
|
|
103
179
|
);
|
|
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
|
+
}
|
|
104
189
|
for (const entityId of credential.entityIds) {
|
|
105
190
|
await this.moduleRepository.unsetCredential(entityId);
|
|
106
191
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Re-authorization of a credential the caller owns (Management API v2). It
|
|
@@ -14,7 +15,13 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
|
|
|
14
15
|
{ code: 'CREDENTIAL_TYPE_UNKNOWN' }
|
|
15
16
|
);
|
|
16
17
|
}
|
|
17
|
-
return {
|
|
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
|
+
};
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/** GET /api/v2/credentials/:credentialId/reauthorize */
|
|
@@ -25,14 +32,12 @@ class GetCredentialReauthorization {
|
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
|
|
28
|
-
const
|
|
29
|
-
this.getCredentialForUser,
|
|
30
|
-
credentialId,
|
|
31
|
-
user
|
|
32
|
-
);
|
|
35
|
+
const { entityType, credentialId: boundCredentialId } =
|
|
36
|
+
await credentialEntityType(this.getCredentialForUser, credentialId, user);
|
|
33
37
|
return this.getAuthorizationStep.execute({
|
|
34
38
|
user,
|
|
35
|
-
|
|
39
|
+
entityType,
|
|
40
|
+
credentialId: boundCredentialId,
|
|
36
41
|
step,
|
|
37
42
|
sessionId,
|
|
38
43
|
state,
|
|
@@ -48,11 +53,13 @@ class ReauthorizeCredentialForUser {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
|
|
51
|
-
const binding = await credentialEntityType(
|
|
56
|
+
const { access, ...binding } = await credentialEntityType(
|
|
52
57
|
this.getCredentialForUser,
|
|
53
58
|
credentialId,
|
|
54
59
|
user
|
|
55
60
|
);
|
|
61
|
+
// A credential an organization owns: the organization's admins only.
|
|
62
|
+
assertCanManage(access, `credential ${credentialId}`);
|
|
56
63
|
return this.submitAuthorizationStep.execute({
|
|
57
64
|
user,
|
|
58
65
|
...binding,
|
|
@@ -279,9 +279,11 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
279
279
|
} = useCases;
|
|
280
280
|
|
|
281
281
|
// The owner of an integration the caller may reach (404 otherwise), which is
|
|
282
|
-
// what the id-and-owner use cases below expect.
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
// what the id-and-owner use cases below expect. `manage: true` is for
|
|
283
|
+
// changing or deleting: an organization's integration needs an admin (403).
|
|
284
|
+
const ownerOf = async (integrationId, user, options) =>
|
|
285
|
+
(await getOwnedIntegrationRecord.execute(integrationId, user, options))
|
|
286
|
+
.userId;
|
|
285
287
|
|
|
286
288
|
router.route('/api/integrations').get(
|
|
287
289
|
catchAsyncError(async (req, res) => {
|
|
@@ -333,7 +335,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
333
335
|
|
|
334
336
|
const integration = await updateIntegration.execute(
|
|
335
337
|
req.params.integrationId,
|
|
336
|
-
await ownerOf(req.params.integrationId, user),
|
|
338
|
+
await ownerOf(req.params.integrationId, user, { manage: true }),
|
|
337
339
|
params.config
|
|
338
340
|
);
|
|
339
341
|
res.json(integration);
|
|
@@ -346,7 +348,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
|
|
|
346
348
|
const params = checkRequiredParams(req.params, ['integrationId']);
|
|
347
349
|
await deleteIntegrationForUser.execute(
|
|
348
350
|
params.integrationId,
|
|
349
|
-
await ownerOf(params.integrationId, user)
|
|
351
|
+
await ownerOf(params.integrationId, user, { manage: true })
|
|
350
352
|
);
|
|
351
353
|
res.status(204).json({});
|
|
352
354
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { describeRecord } = require('../../modules/scope-visibility');
|
|
3
3
|
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
4
|
+
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
4
5
|
const { isIntegrationNotFound } = require('../repositories/integration-not-found');
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -32,7 +33,14 @@ class GetOwnedIntegrationRecord {
|
|
|
32
33
|
return describeRecord(record, this.scopePolicy.accessContext(user), 'integration');
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
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
|
}
|
|
@@ -206,6 +206,7 @@ const SCHEMAS = {
|
|
|
206
206
|
authIsValid: nullable({ type: 'boolean' }),
|
|
207
207
|
entityIds: { type: 'array', items: { type: 'string' } },
|
|
208
208
|
entityCount: { type: 'integer', minimum: 0 },
|
|
209
|
+
...tenancyProperties,
|
|
209
210
|
createdAt: nullable({ type: 'string', format: 'date-time' }),
|
|
210
211
|
updatedAt: nullable({ type: 'string', format: 'date-time' }),
|
|
211
212
|
data: {
|
|
@@ -184,6 +184,9 @@ const V2_OPERATIONS = {
|
|
|
184
184
|
// Credentials
|
|
185
185
|
listCredentials: {
|
|
186
186
|
summary: "List the caller's credentials (masked)",
|
|
187
|
+
description:
|
|
188
|
+
'A credential is listed through the entities that use it that the caller may see, so each organization lists only its own connections; one no entity uses is listed to its owner. Each carries `scope`, `owner`, `organizationId` and `access`, taken from who owns the credential.',
|
|
189
|
+
parameters: listFilters,
|
|
187
190
|
responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
|
|
188
191
|
},
|
|
189
192
|
getCredential: {
|
|
@@ -124,6 +124,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
124
124
|
const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
|
|
125
125
|
credentialRepository,
|
|
126
126
|
moduleRepository,
|
|
127
|
+
scopePolicy,
|
|
127
128
|
});
|
|
128
129
|
const getAuthorizationRequirements = new GetAuthorizationRequirements({
|
|
129
130
|
moduleDefinitions,
|
|
@@ -246,6 +247,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
246
247
|
listCredentialsForUser: new credentialOps.ListCredentialsForUser({
|
|
247
248
|
credentialRepository,
|
|
248
249
|
moduleRepository,
|
|
250
|
+
scopePolicy,
|
|
249
251
|
}),
|
|
250
252
|
getCredentialForUser,
|
|
251
253
|
deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
|
package/management-api/v2/dto.js
CHANGED
|
@@ -75,6 +75,8 @@ const CREDENTIAL_METADATA = new Set([
|
|
|
75
75
|
'type',
|
|
76
76
|
'entityIds',
|
|
77
77
|
'status',
|
|
78
|
+
// Added by the credential use cases (ADR-056 §3.4); never provider data.
|
|
79
|
+
'tenancy',
|
|
78
80
|
]);
|
|
79
81
|
|
|
80
82
|
/**
|
|
@@ -126,6 +128,7 @@ function maskCredential(credential) {
|
|
|
126
128
|
entityCount: entityIds.length,
|
|
127
129
|
createdAt: toIso(credential.createdAt),
|
|
128
130
|
updatedAt: toIso(credential.updatedAt),
|
|
131
|
+
...tenancyFields(credential.tenancy),
|
|
129
132
|
data,
|
|
130
133
|
};
|
|
131
134
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { maskCredential } = require('../dto');
|
|
2
|
+
const { parseListFilters } = require('../request');
|
|
2
3
|
|
|
3
4
|
/** Management API v2 credential handlers, keyed by operationId. */
|
|
4
5
|
function createCredentialHandlers(useCases) {
|
|
@@ -6,8 +7,11 @@ function createCredentialHandlers(useCases) {
|
|
|
6
7
|
useCases;
|
|
7
8
|
|
|
8
9
|
return {
|
|
9
|
-
async listCredentials(
|
|
10
|
-
const credentials = await listCredentialsForUser.execute(
|
|
10
|
+
async listCredentials(req, res, user) {
|
|
11
|
+
const credentials = await listCredentialsForUser.execute(
|
|
12
|
+
user,
|
|
13
|
+
parseListFilters(req.query)
|
|
14
|
+
);
|
|
11
15
|
res.json({ credentials: credentials.map(maskCredential) });
|
|
12
16
|
},
|
|
13
17
|
|
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,26 @@ function readIntegrationOwnership(integrationClass) {
|
|
|
75
76
|
return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner };
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* `user.orgAdminRoles`: the token roles that may manage organization-level
|
|
81
|
+
* things. Default `['ADMIN']`. A shared-secret caller needs no role.
|
|
82
|
+
*/
|
|
83
|
+
function readOrgAdminRoles(appDefinition) {
|
|
84
|
+
const raw = appDefinition?.user?.orgAdminRoles;
|
|
85
|
+
if (raw === undefined || raw === null) return [...DEFAULT_ORG_ADMIN_ROLES];
|
|
86
|
+
if (
|
|
87
|
+
!Array.isArray(raw) ||
|
|
88
|
+
raw.length === 0 ||
|
|
89
|
+
raw.some((role) => typeof role !== 'string' || role.trim() === '')
|
|
90
|
+
) {
|
|
91
|
+
throw configError(
|
|
92
|
+
'appDefinition.user.orgAdminRoles',
|
|
93
|
+
'must be a non-empty array of role names'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return [...raw];
|
|
97
|
+
}
|
|
98
|
+
|
|
78
99
|
/**
|
|
79
100
|
* Every binding of every integration with its resolved scope and where it came
|
|
80
101
|
* from, parsing each integration's settings once. Throws OwnershipConfigError
|
|
@@ -118,7 +139,7 @@ function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
|
|
|
118
139
|
* Validates every ownership setting in one pass and returns the app-level
|
|
119
140
|
* config the runtime needs.
|
|
120
141
|
*
|
|
121
|
-
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string}}
|
|
142
|
+
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[]}}
|
|
122
143
|
* @throws {OwnershipConfigError}
|
|
123
144
|
*/
|
|
124
145
|
function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
@@ -143,6 +164,7 @@ function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
|
143
164
|
return Object.freeze({
|
|
144
165
|
scope: app.scope,
|
|
145
166
|
individualAcross: app.individualAcross,
|
|
167
|
+
orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
|
|
146
168
|
});
|
|
147
169
|
}
|
|
148
170
|
|
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[]}} [params.ownership] - the raw
|
|
19
24
|
* `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
|
|
20
25
|
* `{ default, allow }` parses to itself)
|
|
21
26
|
* @param {Map} [params.entityTypeRegistry]
|
|
@@ -33,7 +38,10 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
33
38
|
);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
const
|
|
41
|
+
const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
|
|
42
|
+
|
|
43
|
+
const contextFor = (user) =>
|
|
44
|
+
accessContext(user, { individualAcross, orgAdminRoles });
|
|
37
45
|
|
|
38
46
|
/** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
|
|
39
47
|
const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
|
|
@@ -61,15 +69,38 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
61
69
|
};
|
|
62
70
|
};
|
|
63
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The owner and organization for a scope that is already decided: a
|
|
74
|
+
* re-authorization keeps the scope its connection has, whatever the config
|
|
75
|
+
* says now. The admin gate applies as it does on create.
|
|
76
|
+
*/
|
|
77
|
+
const ownershipForScope = ({ scope, user }) => {
|
|
78
|
+
const ctx = contextFor(user);
|
|
79
|
+
return {
|
|
80
|
+
...resolveOwnerForScope(scope, ctx),
|
|
81
|
+
legacyOrgId: ctx.legacyOrgId,
|
|
82
|
+
source: 'fixed',
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
64
86
|
/** Whether `user` may load this stored entity for use (same clauses as the list). */
|
|
65
87
|
const canUseEntity = (user, entity) =>
|
|
66
88
|
describeRecord(entity, contextFor(user), 'entity') !== null;
|
|
67
89
|
|
|
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
|
+
|
|
68
96
|
return Object.freeze({
|
|
69
97
|
individualAcross,
|
|
98
|
+
orgAdminRoles,
|
|
99
|
+
accessToOwnedBy,
|
|
70
100
|
accessContext: contextFor,
|
|
71
101
|
resolveScope: resolveEntityScope,
|
|
72
102
|
resolveOwnership,
|
|
103
|
+
ownershipForScope,
|
|
73
104
|
canUseEntity,
|
|
74
105
|
});
|
|
75
106
|
}
|
|
@@ -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,35 @@ function buildClauses(base, kind) {
|
|
|
66
67
|
return clauses;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Whether the caller may manage organization-level things (create, change,
|
|
72
|
+
* delete). A shared-secret caller is the app's own backend and may. A person
|
|
73
|
+
* with a token has the role the token carries, and `MEMBER` when it carries
|
|
74
|
+
* none: a missing role never means admin. An unknown source (a `User` built
|
|
75
|
+
* outside AuthenticateUser) is treated like a token.
|
|
76
|
+
*
|
|
77
|
+
* @param {{authSource: (string|null), orgRole: (string|null)}} who
|
|
78
|
+
* @param {string[]} adminRoles - `user.orgAdminRoles`, compared case-insensitively
|
|
79
|
+
*/
|
|
80
|
+
function canManageOrganization({ authSource, orgRole }, adminRoles = DEFAULT_ORG_ADMIN_ROLES) {
|
|
81
|
+
if (authSource === AUTH_SOURCES.SHARED_SECRET) return true;
|
|
82
|
+
const role = String(isPresent(orgRole) ? orgRole : 'MEMBER').toUpperCase();
|
|
83
|
+
return adminRoles.some((admin) => String(admin).toUpperCase() === role);
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
/**
|
|
70
87
|
* Who the caller is, for ownership and visibility: their individual user, the
|
|
71
|
-
* organization this request acts in, the legacy `organizationId` pointer,
|
|
72
|
-
*
|
|
88
|
+
* organization this request acts in, the legacy `organizationId` pointer,
|
|
89
|
+
* whether they may manage organization-level things, and the clauses that
|
|
90
|
+
* follow from them (computed once).
|
|
73
91
|
*
|
|
74
92
|
* @param {Object} user - the authenticated `User` (or a `{ getId }` double)
|
|
75
|
-
* @param {{individualAcross?: string}} [options]
|
|
93
|
+
* @param {{individualAcross?: string, orgAdminRoles?: string[]}} [options]
|
|
76
94
|
*/
|
|
77
|
-
function accessContext(
|
|
95
|
+
function accessContext(
|
|
96
|
+
user,
|
|
97
|
+
{ individualAcross = 'organization', orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES } = {}
|
|
98
|
+
) {
|
|
78
99
|
const hasUserModel = typeof user?.getIndividualUser === 'function';
|
|
79
100
|
const individual = hasUserModel ? user.getIndividualUser() : null;
|
|
80
101
|
const base = {
|
|
@@ -86,8 +107,14 @@ function accessContext(user, { individualAcross = 'organization' } = {}) {
|
|
|
86
107
|
legacyOrgId: idString(individual?.organizationId),
|
|
87
108
|
individualAcross,
|
|
88
109
|
};
|
|
110
|
+
const who = {
|
|
111
|
+
authSource:
|
|
112
|
+
typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
|
|
113
|
+
orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
|
|
114
|
+
};
|
|
89
115
|
return {
|
|
90
116
|
...base,
|
|
117
|
+
canManageOrg: canManageOrganization(who, orgAdminRoles),
|
|
91
118
|
clauses: {
|
|
92
119
|
entity: buildClauses(base, 'entity'),
|
|
93
120
|
integration: buildClauses(base, 'integration'),
|
|
@@ -112,8 +139,26 @@ function scopeOf(record, ctx) {
|
|
|
112
139
|
return null;
|
|
113
140
|
}
|
|
114
141
|
|
|
115
|
-
|
|
116
|
-
|
|
142
|
+
/** manage for the person's own; for the organization's, only an admin; use otherwise. */
|
|
143
|
+
const accessFor = (scope, ctx) => {
|
|
144
|
+
if (scope === SCOPES.INDIVIDUAL) return ACCESS.MANAGE;
|
|
145
|
+
if (scope === SCOPES.ORGANIZATION) {
|
|
146
|
+
return ctx.canManageOrg ? ACCESS.MANAGE : ACCESS.USE;
|
|
147
|
+
}
|
|
148
|
+
return ACCESS.USE;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What the caller may do with a record owned by `ownerId` that carries no
|
|
153
|
+
* organization of its own (a credential): manage the person's own, manage the
|
|
154
|
+
* active organization's only as an admin, nothing otherwise. Null when the
|
|
155
|
+
* record is not theirs.
|
|
156
|
+
*/
|
|
157
|
+
function accessToOwner(ownerId, ctx) {
|
|
158
|
+
const scope = scopeOf({ userId: ownerId }, ctx);
|
|
159
|
+
if (scope !== SCOPES.INDIVIDUAL && scope !== SCOPES.ORGANIZATION) return null;
|
|
160
|
+
return accessFor(scope, ctx);
|
|
161
|
+
}
|
|
117
162
|
|
|
118
163
|
/**
|
|
119
164
|
* What a row is to this caller, or null when they may not see it.
|
|
@@ -134,7 +179,7 @@ function describeRecord(record, ctx, kind) {
|
|
|
134
179
|
scope === SCOPES.ORGANIZATION
|
|
135
180
|
? idString(record.userId)
|
|
136
181
|
: idString(record.organizationId),
|
|
137
|
-
access: accessFor(scope),
|
|
182
|
+
access: accessFor(scope, ctx),
|
|
138
183
|
};
|
|
139
184
|
}
|
|
140
185
|
|
|
@@ -154,6 +199,8 @@ function matchesListFilters(description, { scope, owner } = {}) {
|
|
|
154
199
|
}
|
|
155
200
|
|
|
156
201
|
module.exports = {
|
|
202
|
+
canManageOrganization,
|
|
203
|
+
accessToOwner,
|
|
157
204
|
accessContext,
|
|
158
205
|
unscopedRowsBelongHere,
|
|
159
206
|
visibilityClauses,
|
|
@@ -19,9 +19,11 @@ class AuthorizeEntityForUser {
|
|
|
19
19
|
* The scope, owner and organization a new entity of `entityType` gets for
|
|
20
20
|
* this caller. Throws a 400/403 when the request cannot have one.
|
|
21
21
|
*
|
|
22
|
-
* @param {{user: Object, entityType: string, scope?: string}} params
|
|
22
|
+
* @param {{user: Object, entityType: string, scope?: string, fixedScope?: string}} params
|
|
23
|
+
* `fixedScope`: a re-authorization keeps the scope its connection has
|
|
23
24
|
*/
|
|
24
|
-
resolveOwnership({ user, entityType, scope }) {
|
|
25
|
+
resolveOwnership({ user, entityType, scope, fixedScope }) {
|
|
26
|
+
if (fixedScope) return this.scopePolicy.ownershipForScope({ scope: fixedScope, user });
|
|
25
27
|
return this.scopePolicy.resolveOwnership({
|
|
26
28
|
entityType,
|
|
27
29
|
requestScope: scope,
|
|
@@ -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
|
|
@@ -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 };
|
|
@@ -166,8 +166,10 @@ class SubmitAuthorizationStep {
|
|
|
166
166
|
* @param {Object} params
|
|
167
167
|
* @param {string} [params.scope] - the scope the caller asks for, from the
|
|
168
168
|
* deciding level's allow list (ADR-056 §3.1). Never `global`.
|
|
169
|
+
* @param {string} [params.fixedScope] - re-authorization keeps the scope the
|
|
170
|
+
* credential already has, whatever the config says now.
|
|
169
171
|
*/
|
|
170
|
-
async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope }) {
|
|
172
|
+
async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope, fixedScope }) {
|
|
171
173
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
172
174
|
throw Boom.badRequest('data must be an object', {
|
|
173
175
|
code: 'VALIDATION_ERROR',
|
|
@@ -183,6 +185,7 @@ class SubmitAuthorizationStep {
|
|
|
183
185
|
user,
|
|
184
186
|
entityType,
|
|
185
187
|
scope,
|
|
188
|
+
fixedScope,
|
|
186
189
|
});
|
|
187
190
|
|
|
188
191
|
if (!isMultiStep(definition)) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.684.e113124.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.684.e113124.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.684.e113124.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.684.e113124.0",
|
|
54
54
|
"@prisma/client": "^6.19.3",
|
|
55
55
|
"@types/lodash": "4.17.15",
|
|
56
56
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -90,5 +90,5 @@
|
|
|
90
90
|
"publishConfig": {
|
|
91
91
|
"access": "public"
|
|
92
92
|
},
|
|
93
|
-
"gitHead": "
|
|
93
|
+
"gitHead": "e1131240a890bf30cfee08ba3786458c9e2d1135"
|
|
94
94
|
}
|
package/types/user/index.d.ts
CHANGED
|
@@ -62,6 +62,13 @@ declare module "@friggframework/core/user" {
|
|
|
62
62
|
* at startup. `strictMembership` wins when both are set.
|
|
63
63
|
*/
|
|
64
64
|
strictUserValidation?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Roles (the adopter token's role claim) that may manage organization-level
|
|
67
|
+
* things (ADR-056 §3). Default `['ADMIN']`; a token with no role claim is
|
|
68
|
+
* `MEMBER`, never admin; a shared-secret (`x-frigg` header) caller needs no
|
|
69
|
+
* role. Compared case-insensitively. Validated at load.
|
|
70
|
+
*/
|
|
71
|
+
orgAdminRoles?: string[];
|
|
65
72
|
authModes?: UserAuthModes;
|
|
66
73
|
jwtConfig?: AdopterJwtConfig;
|
|
67
74
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { getLogger } = require('../../logs');
|
|
3
|
+
const { AUTH_SOURCES } = require('../user');
|
|
3
4
|
|
|
4
5
|
const log = getLogger('frigg.user.authentication');
|
|
5
6
|
|
|
@@ -68,10 +69,13 @@ class AuthenticateUser {
|
|
|
68
69
|
// Validate the API key (authentication)
|
|
69
70
|
await this.authenticateWithSharedSecret.execute(apiKey);
|
|
70
71
|
// Get user from x-frigg headers (authorization)
|
|
71
|
-
|
|
72
|
+
const user = await this.getUserFromXFriggHeaders.execute(
|
|
72
73
|
appUserId,
|
|
73
74
|
appOrgId
|
|
74
75
|
);
|
|
76
|
+
// The app's own backend: trusted to manage organization things.
|
|
77
|
+
user.setAuthSource(AUTH_SOURCES.SHARED_SECRET);
|
|
78
|
+
return user;
|
|
75
79
|
}
|
|
76
80
|
log.debug('No x-frigg-api-key header, skipping shared secret', {
|
|
77
81
|
eventName: `${log.name}.shared_secret_skipped`,
|
|
@@ -90,6 +94,7 @@ class AuthenticateUser {
|
|
|
90
94
|
// Detect JWT format (3 parts separated by dots)
|
|
91
95
|
if (token && token.split('.').length === 3) {
|
|
92
96
|
const user = await this.getUserFromAdopterJwt.execute(token);
|
|
97
|
+
user.setAuthSource(AUTH_SOURCES.ADOPTER_JWT);
|
|
93
98
|
// Validate x-frigg headers match JWT claims if present
|
|
94
99
|
if (appUserId || appOrgId) {
|
|
95
100
|
this.validateUserMatch(user, appUserId, appOrgId);
|
|
@@ -106,6 +111,7 @@ class AuthenticateUser {
|
|
|
106
111
|
const user = await this.getUserFromBearerToken.execute(
|
|
107
112
|
req.headers.authorization
|
|
108
113
|
);
|
|
114
|
+
user.setAuthSource(AUTH_SOURCES.BEARER_TOKEN);
|
|
109
115
|
// Validate x-frigg headers match token user if present
|
|
110
116
|
if (appUserId || appOrgId) {
|
|
111
117
|
this.validateUserMatch(user, appUserId, appOrgId);
|
package/user/user.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
const bcrypt = require('bcryptjs');
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* How the request that produced a `User` proved who it is. Set by
|
|
5
|
+
* AuthenticateUser, never inferred from a missing value. Organization roles
|
|
6
|
+
* (ADR-056 §3, §3.6) depend on it: a shared-secret caller is the app's own
|
|
7
|
+
* backend and is trusted to manage; a person with a token has the role the
|
|
8
|
+
* token carries, or `MEMBER`.
|
|
9
|
+
*/
|
|
10
|
+
const AUTH_SOURCES = Object.freeze({
|
|
11
|
+
SHARED_SECRET: 'shared-secret',
|
|
12
|
+
ADOPTER_JWT: 'adopter-jwt',
|
|
13
|
+
BEARER_TOKEN: 'bearer-token',
|
|
14
|
+
});
|
|
15
|
+
|
|
3
16
|
/**
|
|
4
17
|
* Represents a user in the system. The User class is a domain entity,
|
|
5
18
|
* @class User
|
|
@@ -21,6 +34,8 @@ class User {
|
|
|
21
34
|
// Organization role from an adopter token claim (ADR-056 §2). Per request:
|
|
22
35
|
// the role stored on a membership is only what the first token carried.
|
|
23
36
|
this.orgRole = null;
|
|
37
|
+
// How this request authenticated (one of AUTH_SOURCES); null until AuthenticateUser says.
|
|
38
|
+
this.authSource = null;
|
|
24
39
|
|
|
25
40
|
this.config = {
|
|
26
41
|
primary,
|
|
@@ -84,6 +99,14 @@ class User {
|
|
|
84
99
|
return this.config.individualUserRequired;
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
getAuthSource() {
|
|
103
|
+
return this.authSource;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setAuthSource(authSource) {
|
|
107
|
+
this.authSource = authSource ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
getOrgRole() {
|
|
88
111
|
return this.orgRole;
|
|
89
112
|
}
|
|
@@ -149,4 +172,4 @@ class User {
|
|
|
149
172
|
}
|
|
150
173
|
}
|
|
151
174
|
|
|
152
|
-
module.exports = { User };
|
|
175
|
+
module.exports = { User, AUTH_SOURCES };
|