@friggframework/core 2.0.0--canary.685.022c3c3.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/CLAUDE.md +0 -23
- package/README.md +1 -1
- package/credential/use-cases/credential-operations-for-user.js +33 -110
- package/credential/use-cases/reauthorize-credential-for-user.js +13 -10
- package/handlers/app-definition-loader.js +0 -9
- package/index.js +3 -4
- package/integrations/index.js +5 -0
- package/integrations/integration-router.js +26 -28
- package/integrations/repositories/integration-repository-documentdb.js +2 -1
- package/integrations/utils/map-integration-dto.js +44 -1
- package/management-api/management-api-config.js +4 -70
- package/management-api/openapi/components.js +0 -1
- package/management-api/openapi/v2-operations.js +3 -6
- package/management-api/v2/dependencies.js +2 -2
- package/management-api/v2/dto.js +0 -3
- package/management-api/v2/handlers/credentials.js +2 -6
- package/modules/repositories/module-repository-documentdb.js +8 -6
- package/modules/repositories/module-repository-mongo.js +1 -2
- package/modules/repositories/module-repository-postgres.js +1 -2
- package/modules/repositories/module-repository.js +1 -2
- package/modules/scope-config.js +16 -1
- package/modules/scope-policy.js +3 -17
- package/modules/scope-visibility.js +37 -18
- package/modules/use-cases/authorize-entity-for-user.js +2 -4
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/execute-entity-proxy-request.js +18 -50
- package/modules/use-cases/get-entity-module-for-user.js +2 -9
- 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/multi-step-authorization.js +1 -4
- 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 +9 -41
- package/types/user/index.d.ts +14 -4
- package/user/use-cases/get-user-from-adopter-jwt.js +17 -7
- package/user/user.js +17 -4
- package/modules/repositories/entity-not-found.js +0 -18
|
@@ -165,14 +165,14 @@ const V2_OPERATIONS = {
|
|
|
165
165
|
proxyEntityRequest: {
|
|
166
166
|
summary: "Call the entity's API through Frigg (beta)",
|
|
167
167
|
description:
|
|
168
|
-
"ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list.
|
|
168
|
+
"ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
|
|
169
169
|
requestBody: body(ref('ProxyRequest')),
|
|
170
170
|
responses: {
|
|
171
171
|
200: ok('Upstream answer (the status mirrors the upstream status)', ref('ProxyResponse')),
|
|
172
172
|
400: ok('INVALID_PROXY_REQUEST', ref('ProxyError')),
|
|
173
173
|
401: ok('INVALID_CREDENTIALS or unauthenticated', ref('ProxyError')),
|
|
174
|
-
403: ok(
|
|
175
|
-
404: ok('Entity not found
|
|
174
|
+
403: ok('PROXY_NOT_ALLOWED', ref('ProxyError')),
|
|
175
|
+
404: ok('Entity not found', ref('Error')),
|
|
176
176
|
413: ok('PAYLOAD_TOO_LARGE', ref('ProxyError')),
|
|
177
177
|
415: ok('UNSUPPORTED_MEDIA_TYPE', ref('ProxyError')),
|
|
178
178
|
429: ok('RATE_LIMITED (Retry-After passed through)', ref('ProxyError')),
|
|
@@ -184,9 +184,6 @@ const V2_OPERATIONS = {
|
|
|
184
184
|
// Credentials
|
|
185
185
|
listCredentials: {
|
|
186
186
|
summary: "List the caller's credentials (masked)",
|
|
187
|
-
description:
|
|
188
|
-
'A credential is listed through the entities that use it that the caller may see, so each organization lists only its own connections; one no entity uses is listed to its owner. Each carries `scope`, `owner`, `organizationId` and `access`, taken from who owns the credential.',
|
|
189
|
-
parameters: listFilters,
|
|
190
187
|
responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
|
|
191
188
|
},
|
|
192
189
|
getCredential: {
|
|
@@ -124,7 +124,6 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
124
124
|
const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
|
|
125
125
|
credentialRepository,
|
|
126
126
|
moduleRepository,
|
|
127
|
-
scopePolicy,
|
|
128
127
|
});
|
|
129
128
|
const getAuthorizationRequirements = new GetAuthorizationRequirements({
|
|
130
129
|
moduleDefinitions,
|
|
@@ -247,13 +246,13 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
247
246
|
listCredentialsForUser: new credentialOps.ListCredentialsForUser({
|
|
248
247
|
credentialRepository,
|
|
249
248
|
moduleRepository,
|
|
250
|
-
scopePolicy,
|
|
251
249
|
}),
|
|
252
250
|
getCredentialForUser,
|
|
253
251
|
deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
|
|
254
252
|
credentialRepository,
|
|
255
253
|
moduleRepository,
|
|
256
254
|
getCredentialForUser,
|
|
255
|
+
scopePolicy,
|
|
257
256
|
}),
|
|
258
257
|
getCredentialReauthorization: new GetCredentialReauthorization({
|
|
259
258
|
getCredentialForUser,
|
|
@@ -262,6 +261,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
262
261
|
reauthorizeCredentialForUser: new ReauthorizeCredentialForUser({
|
|
263
262
|
getCredentialForUser,
|
|
264
263
|
submitAuthorizationStep,
|
|
264
|
+
scopePolicy,
|
|
265
265
|
}),
|
|
266
266
|
|
|
267
267
|
// Authorization (multi-step)
|
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
|
|
|
@@ -3,13 +3,13 @@ const {
|
|
|
3
3
|
toObjectId,
|
|
4
4
|
fromObjectId,
|
|
5
5
|
findMany,
|
|
6
|
+
findManyDrained,
|
|
6
7
|
findOne,
|
|
7
8
|
insertOne,
|
|
8
9
|
updateOne,
|
|
9
10
|
deleteOne,
|
|
10
11
|
} = require('../../database/documentdb-utils');
|
|
11
12
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
12
|
-
const { entityNotFoundError } = require('./entity-not-found');
|
|
13
13
|
const { DocumentDBEncryptionService } = require('../../database/documentdb-encryption-service');
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -34,11 +34,11 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
|
|
|
34
34
|
async findEntityById(entityId) {
|
|
35
35
|
const objectId = toObjectId(entityId);
|
|
36
36
|
if (!objectId) {
|
|
37
|
-
throw
|
|
37
|
+
throw new Error(`Entity ${entityId} not found`);
|
|
38
38
|
}
|
|
39
39
|
const doc = await findOne(this.prisma, 'Entity', { _id: objectId });
|
|
40
40
|
if (!doc) {
|
|
41
|
-
throw
|
|
41
|
+
throw new Error(`Entity ${entityId} not found`);
|
|
42
42
|
}
|
|
43
43
|
const credential = await this._fetchCredential(doc.credentialId);
|
|
44
44
|
return this._mapEntity(doc, credential);
|
|
@@ -88,7 +88,8 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
|
|
|
88
88
|
*/
|
|
89
89
|
async findEntitiesByOwnership(clauses) {
|
|
90
90
|
if (!Array.isArray(clauses) || clauses.length === 0) return [];
|
|
91
|
-
|
|
91
|
+
// Drained: a list can hold more than the first cursor batch (~101).
|
|
92
|
+
const docs = await findManyDrained(this.prisma, 'Entity', {
|
|
92
93
|
$or: clauses.map(({ userId, organizationId }) => ({
|
|
93
94
|
userId: toObjectId(userId),
|
|
94
95
|
organizationId:
|
|
@@ -278,8 +279,9 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
|
|
|
278
279
|
const objectIds = ids.map(id => toObjectId(id)).filter(Boolean);
|
|
279
280
|
if (objectIds.length === 0) return new Map();
|
|
280
281
|
|
|
281
|
-
//
|
|
282
|
-
|
|
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', {
|
|
283
285
|
_id: { $in: objectIds }
|
|
284
286
|
});
|
|
285
287
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
2
|
const { organizationWhere } = require('../../database/organization-where');
|
|
3
3
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
4
|
-
const { entityNotFoundError } = require('./entity-not-found');
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* MongoDB Module Repository Adapter
|
|
@@ -112,7 +111,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
|
|
|
112
111
|
});
|
|
113
112
|
|
|
114
113
|
if (!entity) {
|
|
115
|
-
throw
|
|
114
|
+
throw new Error(`Entity ${entityId} not found`);
|
|
116
115
|
}
|
|
117
116
|
|
|
118
117
|
const credential = await this._fetchCredential(entity.credentialId);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
2
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
3
|
-
const { entityNotFoundError } = require('./entity-not-found');
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* PostgreSQL Module Repository Adapter
|
|
@@ -145,7 +144,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
145
144
|
});
|
|
146
145
|
|
|
147
146
|
if (!entity) {
|
|
148
|
-
throw
|
|
147
|
+
throw new Error(`Entity ${entityId} not found`);
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
const credential = await this._fetchCredential(entity.credentialId);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
2
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
3
|
-
const { entityNotFoundError } = require('./entity-not-found');
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* Prisma-based Module Repository
|
|
@@ -38,7 +37,7 @@ class ModuleRepository extends ModuleRepositoryInterface {
|
|
|
38
37
|
});
|
|
39
38
|
|
|
40
39
|
if (!entity) {
|
|
41
|
-
throw
|
|
40
|
+
throw new Error(`Entity ${entityId} not found`);
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
return {
|
package/modules/scope-config.js
CHANGED
|
@@ -96,6 +96,20 @@ function readOrgAdminRoles(appDefinition) {
|
|
|
96
96
|
return [...raw];
|
|
97
97
|
}
|
|
98
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
|
+
|
|
99
113
|
/**
|
|
100
114
|
* Every binding of every integration with its resolved scope and where it came
|
|
101
115
|
* from, parsing each integration's settings once. Throws OwnershipConfigError
|
|
@@ -139,7 +153,7 @@ function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
|
|
|
139
153
|
* Validates every ownership setting in one pass and returns the app-level
|
|
140
154
|
* config the runtime needs.
|
|
141
155
|
*
|
|
142
|
-
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[]}}
|
|
156
|
+
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[], gateBearerTokens: boolean}}
|
|
143
157
|
* @throws {OwnershipConfigError}
|
|
144
158
|
*/
|
|
145
159
|
function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
@@ -165,6 +179,7 @@ function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
|
165
179
|
scope: app.scope,
|
|
166
180
|
individualAcross: app.individualAcross,
|
|
167
181
|
orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
|
|
182
|
+
gateBearerTokens: readGateBearerTokens(appDefinition),
|
|
168
183
|
});
|
|
169
184
|
}
|
|
170
185
|
|
package/modules/scope-policy.js
CHANGED
|
@@ -20,7 +20,7 @@ const {
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* @param {Object} [params]
|
|
23
|
-
* @param {{scope: *, individualAcross: string, orgAdminRoles?: string[]}} [params.ownership] - the raw
|
|
23
|
+
* @param {{scope: *, individualAcross: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [params.ownership] - the raw
|
|
24
24
|
* `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
|
|
25
25
|
* `{ default, allow }` parses to itself)
|
|
26
26
|
* @param {Map} [params.entityTypeRegistry]
|
|
@@ -39,9 +39,10 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
|
|
42
|
+
const gateBearerTokens = ownership?.gateBearerTokens === true;
|
|
42
43
|
|
|
43
44
|
const contextFor = (user) =>
|
|
44
|
-
accessContext(user, { individualAcross, orgAdminRoles });
|
|
45
|
+
accessContext(user, { individualAcross, orgAdminRoles, gateBearerTokens });
|
|
45
46
|
|
|
46
47
|
/** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
|
|
47
48
|
const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
|
|
@@ -69,20 +70,6 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
69
70
|
};
|
|
70
71
|
};
|
|
71
72
|
|
|
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
73
|
/** Whether `user` may load this stored entity for use (same clauses as the list). */
|
|
87
74
|
const canUseEntity = (user, entity) =>
|
|
88
75
|
describeRecord(entity, contextFor(user), 'entity') !== null;
|
|
@@ -100,7 +87,6 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
100
87
|
accessContext: contextFor,
|
|
101
88
|
resolveScope: resolveEntityScope,
|
|
102
89
|
resolveOwnership,
|
|
103
|
-
ownershipForScope,
|
|
104
90
|
canUseEntity,
|
|
105
91
|
});
|
|
106
92
|
}
|
|
@@ -69,17 +69,34 @@ function buildClauses(base, kind) {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Whether the caller may manage organization-level things (create, change,
|
|
72
|
-
* delete)
|
|
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.
|
|
72
|
+
* delete), ADR-056 §3.6:
|
|
76
73
|
*
|
|
77
|
-
*
|
|
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
|
|
78
86
|
* @param {string[]} adminRoles - `user.orgAdminRoles`, compared case-insensitively
|
|
87
|
+
* @param {{gateBearerTokens?: boolean}} [options]
|
|
79
88
|
*/
|
|
80
|
-
function canManageOrganization(
|
|
89
|
+
function canManageOrganization(
|
|
90
|
+
{ authSource, orgRole, membershipRole },
|
|
91
|
+
adminRoles = DEFAULT_ORG_ADMIN_ROLES,
|
|
92
|
+
{ gateBearerTokens = false } = {}
|
|
93
|
+
) {
|
|
81
94
|
if (authSource === AUTH_SOURCES.SHARED_SECRET) return true;
|
|
82
|
-
|
|
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();
|
|
83
100
|
return adminRoles.some((admin) => String(admin).toUpperCase() === role);
|
|
84
101
|
}
|
|
85
102
|
|
|
@@ -90,11 +107,15 @@ function canManageOrganization({ authSource, orgRole }, adminRoles = DEFAULT_ORG
|
|
|
90
107
|
* follow from them (computed once).
|
|
91
108
|
*
|
|
92
109
|
* @param {Object} user - the authenticated `User` (or a `{ getId }` double)
|
|
93
|
-
* @param {{individualAcross?: string, orgAdminRoles?: string[]}} [options]
|
|
110
|
+
* @param {{individualAcross?: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [options]
|
|
94
111
|
*/
|
|
95
112
|
function accessContext(
|
|
96
113
|
user,
|
|
97
|
-
{
|
|
114
|
+
{
|
|
115
|
+
individualAcross = 'organization',
|
|
116
|
+
orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES,
|
|
117
|
+
gateBearerTokens = false,
|
|
118
|
+
} = {}
|
|
98
119
|
) {
|
|
99
120
|
const hasUserModel = typeof user?.getIndividualUser === 'function';
|
|
100
121
|
const individual = hasUserModel ? user.getIndividualUser() : null;
|
|
@@ -111,10 +132,16 @@ function accessContext(
|
|
|
111
132
|
authSource:
|
|
112
133
|
typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
|
|
113
134
|
orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
|
|
135
|
+
membershipRole:
|
|
136
|
+
typeof user?.getMembershipRole === 'function'
|
|
137
|
+
? user.getMembershipRole()
|
|
138
|
+
: null,
|
|
114
139
|
};
|
|
115
140
|
return {
|
|
116
141
|
...base,
|
|
117
|
-
canManageOrg: canManageOrganization(who, orgAdminRoles
|
|
142
|
+
canManageOrg: canManageOrganization(who, orgAdminRoles, {
|
|
143
|
+
gateBearerTokens,
|
|
144
|
+
}),
|
|
118
145
|
clauses: {
|
|
119
146
|
entity: buildClauses(base, 'entity'),
|
|
120
147
|
integration: buildClauses(base, 'integration'),
|
|
@@ -148,13 +175,6 @@ const accessFor = (scope, ctx) => {
|
|
|
148
175
|
return ACCESS.USE;
|
|
149
176
|
};
|
|
150
177
|
|
|
151
|
-
/**
|
|
152
|
-
* Whether an access level lets the caller use a record: bind it, proxy through
|
|
153
|
-
* it (§3.4). `manage` includes `use`; `view` (metadata only) does not, and
|
|
154
|
-
* neither does anything unrecognised.
|
|
155
|
-
*/
|
|
156
|
-
const grantsUse = (access) => access === ACCESS.USE || access === ACCESS.MANAGE;
|
|
157
|
-
|
|
158
178
|
/**
|
|
159
179
|
* What the caller may do with a record owned by `ownerId` that carries no
|
|
160
180
|
* organization of its own (a credential): manage the person's own, manage the
|
|
@@ -214,7 +234,6 @@ module.exports = {
|
|
|
214
234
|
matchesClause,
|
|
215
235
|
scopeOf,
|
|
216
236
|
describeRecord,
|
|
217
|
-
grantsUse,
|
|
218
237
|
LIST_OWNERS,
|
|
219
238
|
matchesListFilters,
|
|
220
239
|
};
|
|
@@ -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,
|
|
@@ -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 };
|
|
@@ -2,8 +2,6 @@ const Boom = require('@hapi/boom');
|
|
|
2
2
|
const { getLogger } = require('../../logs');
|
|
3
3
|
const { getTelemetry } = require('../../telemetry/telemetry-runtime');
|
|
4
4
|
const { ProxyRequestError } = require('../proxy/proxy-errors');
|
|
5
|
-
const { SCOPES } = require('../scope-resolution');
|
|
6
|
-
const { grantsUse } = require('../scope-visibility');
|
|
7
5
|
const { normalizeProxyPath, matchAllowRule } = require('../proxy/proxy-path');
|
|
8
6
|
const {
|
|
9
7
|
validateProxyRequest,
|
|
@@ -57,11 +55,9 @@ const authHeaderNamesOf = (api) =>
|
|
|
57
55
|
* through its module with the stored credential, inside the limits the app
|
|
58
56
|
* and the module set.
|
|
59
57
|
*
|
|
60
|
-
* Order of checks: request shape and size, path rules, entity
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* deny by default), credential readiness (401), then the call through
|
|
64
|
-
* Requester._proxyRequest. Every call,
|
|
58
|
+
* Order of checks: request shape and size, path rules, entity ownership
|
|
59
|
+
* (404), the module's allow-list (403, deny by default), credential
|
|
60
|
+
* readiness (401), then the call through Requester._proxyRequest. Every call,
|
|
65
61
|
* allowed or denied, writes one audit record and one usage count. Bodies,
|
|
66
62
|
* headers, query values and the raw path are never logged.
|
|
67
63
|
*/
|
|
@@ -102,14 +98,24 @@ class ExecuteEntityProxyRequest {
|
|
|
102
98
|
audit.requestBytes = validated.requestBytes;
|
|
103
99
|
const path = normalizeProxyPath(validated.path);
|
|
104
100
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
101
|
+
// Access is `use` in the active organization (ADR-056 §3.5, §3.6): the
|
|
102
|
+
// owner's entity there, or the organization's. A `global` entity is
|
|
103
|
+
// never the tenant's.
|
|
108
104
|
const { entity, definition, module } =
|
|
109
105
|
await this.getEntityModuleForUser.execute(entityId, user);
|
|
110
106
|
audit.module = definition.moduleName;
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
// Narrowest by default (§3.5): proxying through a shared credential
|
|
108
|
+
// lets any member act on the organization's account, so only an
|
|
109
|
+
// individual entity may be proxied until `managementApi.proxy.scopes`
|
|
110
|
+
// lets an app opt in to `organization`.
|
|
111
|
+
// TODO(F6): read proxy.scopes here instead of refusing outright.
|
|
112
|
+
if (this.getEntityModuleForUser.describe(entity, user)?.scope !== 'individual') {
|
|
113
|
+
throw new ProxyRequestError(
|
|
114
|
+
'PROXY_NOT_ALLOWED',
|
|
115
|
+
'The entity proxy only accepts individual entities; organization-level entities are not proxied',
|
|
116
|
+
{ module: definition.moduleName, reason: 'scope' }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
113
119
|
const credentialRef = entity.credential;
|
|
114
120
|
const credentialId =
|
|
115
121
|
credentialRef && typeof credentialRef === 'object'
|
|
@@ -211,44 +217,6 @@ class ExecuteEntityProxyRequest {
|
|
|
211
217
|
}
|
|
212
218
|
}
|
|
213
219
|
|
|
214
|
-
/**
|
|
215
|
-
* ADR-056 §3.5: the caller needs `use` access to the entity in the active
|
|
216
|
-
* organization, and the entity's scope must be one the app lets the proxy
|
|
217
|
-
* accept. `access` and `scope` come from the description the v2 lists
|
|
218
|
-
* return (GetEntityModuleForUser.describe), never from a second rule.
|
|
219
|
-
*
|
|
220
|
-
* Both refusals are 403 PROXY_NOT_ALLOWED: the caller can already see the
|
|
221
|
-
* entity, so saying it exists leaks nothing. `details.reason` tells them
|
|
222
|
-
* apart ('access' or 'scope').
|
|
223
|
-
*/
|
|
224
|
-
assertProxyable(entityId, entity, definition, user) {
|
|
225
|
-
const tenancy = this.getEntityModuleForUser.describe(entity, user);
|
|
226
|
-
if (!tenancy) {
|
|
227
|
-
// Not visible after all: the same 404 as a missing entity.
|
|
228
|
-
throw Boom.notFound(`Entity ${entityId} not found`, {
|
|
229
|
-
code: 'ENTITY_NOT_FOUND',
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
if (!grantsUse(tenancy.access)) {
|
|
233
|
-
throw new ProxyRequestError(
|
|
234
|
-
'PROXY_NOT_ALLOWED',
|
|
235
|
-
'Proxying through an entity needs use access to it; you can only view this one',
|
|
236
|
-
{ module: definition.moduleName, reason: 'access' }
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
// Narrowest by default (§3.5): with no setting only individual
|
|
240
|
-
// entities are proxied, because proxying through a shared credential
|
|
241
|
-
// lets any member act on the organization's account.
|
|
242
|
-
const acceptedScopes = this.proxyConfig.scopes ?? [SCOPES.INDIVIDUAL];
|
|
243
|
-
if (!acceptedScopes.includes(tenancy.scope)) {
|
|
244
|
-
throw new ProxyRequestError(
|
|
245
|
-
'PROXY_NOT_ALLOWED',
|
|
246
|
-
`The entity proxy does not accept ${tenancy.scope} entities in this app`,
|
|
247
|
-
{ module: definition.moduleName, reason: 'scope', scope: tenancy.scope }
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
220
|
/** A module may pin its proxy base URL (string, or a function of the api). */
|
|
253
221
|
resolveBaseUrl(definition, api) {
|
|
254
222
|
const declared = definition.proxy?.baseUrl;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { describeRecord } = require('../scope-visibility');
|
|
3
3
|
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
4
|
-
const { isEntityNotFound } = require('../repositories/entity-not-found');
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* True when `user` may act on records owned by `ownerId`. Uses the User
|
|
@@ -80,14 +79,8 @@ class GetEntityModuleForUser {
|
|
|
80
79
|
try {
|
|
81
80
|
entity = await this.moduleRepository.findEntityById(entityId);
|
|
82
81
|
} catch (error) {
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
// 404 another tenant's entity gets, or the status says which ids
|
|
86
|
-
// exist. Anything else is a real failure.
|
|
87
|
-
if (
|
|
88
|
-
isEntityNotFound(error) ||
|
|
89
|
-
/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')
|
|
90
|
-
) {
|
|
82
|
+
// A malformed id (e.g. not an integer on Postgres) is "not found".
|
|
83
|
+
if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
|
|
91
84
|
entity = null;
|
|
92
85
|
} else {
|
|
93
86
|
throw error;
|
|
@@ -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
|
});
|