@friggframework/core 2.0.0--canary.682.3c8b68a.0 → 2.0.0--canary.685.022c3c3.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 +23 -0
- 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/handlers/app-definition-loader.js +9 -0
- package/index.js +4 -0
- package/integrations/integration-router.js +7 -5
- package/integrations/use-cases/integration-operations-for-user.js +16 -6
- package/management-api/management-api-config.js +70 -4
- package/management-api/openapi/components.js +1 -0
- package/management-api/openapi/v2-operations.js +6 -3
- 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/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +3 -2
- package/modules/repositories/module-repository-mongo.js +2 -1
- package/modules/repositories/module-repository-postgres.js +2 -1
- package/modules/repositories/module-repository.js +2 -1
- 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 +63 -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/execute-entity-proxy-request.js +50 -18
- package/modules/use-cases/get-entity-module-for-user.js +9 -2
- 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/modules/index.d.ts +42 -0
- package/types/user/index.d.ts +7 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/user.js +24 -1
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
deleteOne,
|
|
10
10
|
} = require('../../database/documentdb-utils');
|
|
11
11
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
12
|
+
const { entityNotFoundError } = require('./entity-not-found');
|
|
12
13
|
const { DocumentDBEncryptionService } = require('../../database/documentdb-encryption-service');
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -33,11 +34,11 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
|
|
|
33
34
|
async findEntityById(entityId) {
|
|
34
35
|
const objectId = toObjectId(entityId);
|
|
35
36
|
if (!objectId) {
|
|
36
|
-
throw
|
|
37
|
+
throw entityNotFoundError(entityId);
|
|
37
38
|
}
|
|
38
39
|
const doc = await findOne(this.prisma, 'Entity', { _id: objectId });
|
|
39
40
|
if (!doc) {
|
|
40
|
-
throw
|
|
41
|
+
throw entityNotFoundError(entityId);
|
|
41
42
|
}
|
|
42
43
|
const credential = await this._fetchCredential(doc.credentialId);
|
|
43
44
|
return this._mapEntity(doc, credential);
|
|
@@ -1,6 +1,7 @@
|
|
|
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');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* MongoDB Module Repository Adapter
|
|
@@ -111,7 +112,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
|
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
if (!entity) {
|
|
114
|
-
throw
|
|
115
|
+
throw entityNotFoundError(entityId);
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
const credential = await this._fetchCredential(entity.credentialId);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
2
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
3
|
+
const { entityNotFoundError } = require('./entity-not-found');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* PostgreSQL Module Repository Adapter
|
|
@@ -144,7 +145,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
|
|
|
144
145
|
});
|
|
145
146
|
|
|
146
147
|
if (!entity) {
|
|
147
|
-
throw
|
|
148
|
+
throw entityNotFoundError(entityId);
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
const credential = await this._fetchCredential(entity.credentialId);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
2
|
const { ModuleRepositoryInterface } = require('./module-repository-interface');
|
|
3
|
+
const { entityNotFoundError } = require('./entity-not-found');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Prisma-based Module Repository
|
|
@@ -37,7 +38,7 @@ class ModuleRepository extends ModuleRepositoryInterface {
|
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
if (!entity) {
|
|
40
|
-
throw
|
|
41
|
+
throw entityNotFoundError(entityId);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
return {
|
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,33 @@ 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
|
+
* 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
|
+
/**
|
|
159
|
+
* What the caller may do with a record owned by `ownerId` that carries no
|
|
160
|
+
* organization of its own (a credential): manage the person's own, manage the
|
|
161
|
+
* active organization's only as an admin, nothing otherwise. Null when the
|
|
162
|
+
* record is not theirs.
|
|
163
|
+
*/
|
|
164
|
+
function accessToOwner(ownerId, ctx) {
|
|
165
|
+
const scope = scopeOf({ userId: ownerId }, ctx);
|
|
166
|
+
if (scope !== SCOPES.INDIVIDUAL && scope !== SCOPES.ORGANIZATION) return null;
|
|
167
|
+
return accessFor(scope, ctx);
|
|
168
|
+
}
|
|
117
169
|
|
|
118
170
|
/**
|
|
119
171
|
* What a row is to this caller, or null when they may not see it.
|
|
@@ -134,7 +186,7 @@ function describeRecord(record, ctx, kind) {
|
|
|
134
186
|
scope === SCOPES.ORGANIZATION
|
|
135
187
|
? idString(record.userId)
|
|
136
188
|
: idString(record.organizationId),
|
|
137
|
-
access: accessFor(scope),
|
|
189
|
+
access: accessFor(scope, ctx),
|
|
138
190
|
};
|
|
139
191
|
}
|
|
140
192
|
|
|
@@ -154,12 +206,15 @@ function matchesListFilters(description, { scope, owner } = {}) {
|
|
|
154
206
|
}
|
|
155
207
|
|
|
156
208
|
module.exports = {
|
|
209
|
+
canManageOrganization,
|
|
210
|
+
accessToOwner,
|
|
157
211
|
accessContext,
|
|
158
212
|
unscopedRowsBelongHere,
|
|
159
213
|
visibilityClauses,
|
|
160
214
|
matchesClause,
|
|
161
215
|
scopeOf,
|
|
162
216
|
describeRecord,
|
|
217
|
+
grantsUse,
|
|
163
218
|
LIST_OWNERS,
|
|
164
219
|
matchesListFilters,
|
|
165
220
|
};
|
|
@@ -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
|
|
@@ -2,6 +2,8 @@ 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');
|
|
5
7
|
const { normalizeProxyPath, matchAllowRule } = require('../proxy/proxy-path');
|
|
6
8
|
const {
|
|
7
9
|
validateProxyRequest,
|
|
@@ -55,9 +57,11 @@ const authHeaderNamesOf = (api) =>
|
|
|
55
57
|
* through its module with the stored credential, inside the limits the app
|
|
56
58
|
* and the module set.
|
|
57
59
|
*
|
|
58
|
-
* Order of checks: request shape and size, path rules, entity
|
|
59
|
-
* (404),
|
|
60
|
-
*
|
|
60
|
+
* Order of checks: request shape and size, path rules, entity visibility in
|
|
61
|
+
* the active organization (404), `use` access (403), the entity's scope
|
|
62
|
+
* against `managementApi.proxy.scopes` (403), the module's allow-list (403,
|
|
63
|
+
* deny by default), credential readiness (401), then the call through
|
|
64
|
+
* Requester._proxyRequest. Every call,
|
|
61
65
|
* allowed or denied, writes one audit record and one usage count. Bodies,
|
|
62
66
|
* headers, query values and the raw path are never logged.
|
|
63
67
|
*/
|
|
@@ -98,24 +102,14 @@ class ExecuteEntityProxyRequest {
|
|
|
98
102
|
audit.requestBytes = validated.requestBytes;
|
|
99
103
|
const path = normalizeProxyPath(validated.path);
|
|
100
104
|
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
105
|
+
// Visible in the active organization, or 404 (ADR-056 §3.6): the
|
|
106
|
+
// same clauses the v2 lists use, so a missing entity, another
|
|
107
|
+
// person's and one paired with another organization look alike.
|
|
104
108
|
const { entity, definition, module } =
|
|
105
109
|
await this.getEntityModuleForUser.execute(entityId, user);
|
|
106
110
|
audit.module = definition.moduleName;
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
}
|
|
111
|
+
this.assertProxyable(entityId, entity, definition, user);
|
|
112
|
+
|
|
119
113
|
const credentialRef = entity.credential;
|
|
120
114
|
const credentialId =
|
|
121
115
|
credentialRef && typeof credentialRef === 'object'
|
|
@@ -217,6 +211,44 @@ class ExecuteEntityProxyRequest {
|
|
|
217
211
|
}
|
|
218
212
|
}
|
|
219
213
|
|
|
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
|
+
|
|
220
252
|
/** A module may pin its proxy base URL (string, or a function of the api). */
|
|
221
253
|
resolveBaseUrl(definition, api) {
|
|
222
254
|
const declared = definition.proxy?.baseUrl;
|
|
@@ -1,6 +1,7 @@
|
|
|
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');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* True when `user` may act on records owned by `ownerId`. Uses the User
|
|
@@ -79,8 +80,14 @@ class GetEntityModuleForUser {
|
|
|
79
80
|
try {
|
|
80
81
|
entity = await this.moduleRepository.findEntityById(entityId);
|
|
81
82
|
} catch (error) {
|
|
82
|
-
//
|
|
83
|
-
|
|
83
|
+
// Every adapter throws for a missing row, and a malformed id (e.g.
|
|
84
|
+
// not an integer on Postgres) is "not found" too. Both must be the
|
|
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
|
+
) {
|
|
84
91
|
entity = null;
|
|
85
92
|
} else {
|
|
86
93
|
throw error;
|
|
@@ -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.685.022c3c3.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.685.022c3c3.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.685.022c3c3.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.685.022c3c3.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": "022c3c3ae2d4e9d22046d94911fc00854416cb2a"
|
|
94
94
|
}
|