@friggframework/core 2.0.0--canary.681.2df9ef0.0 → 2.0.0--canary.683.f45da33.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 +37 -1
- package/application/commands/integration-commands.js +20 -10
- package/application/index.js +6 -2
- package/credential/use-cases/credential-operations-for-user.js +9 -1
- package/credential/use-cases/reauthorize-credential-for-user.js +14 -4
- package/database/encryption/encryption-schema-registry.js +7 -0
- package/database/organization-where.js +18 -0
- package/generated/prisma-mongodb/edge.js +5 -3
- package/generated/prisma-mongodb/index-browser.js +2 -0
- package/generated/prisma-mongodb/index.d.ts +115 -2
- package/generated/prisma-mongodb/index.js +5 -3
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +15 -0
- package/generated/prisma-mongodb/wasm.js +5 -3
- package/generated/prisma-postgresql/edge.js +5 -3
- package/generated/prisma-postgresql/index-browser.js +2 -0
- package/generated/prisma-postgresql/index.d.ts +131 -2
- package/generated/prisma-postgresql/index.js +5 -3
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +15 -0
- package/generated/prisma-postgresql/wasm.js +5 -3
- package/handlers/app-definition-loader.js +25 -8
- package/handlers/backend-utils.js +31 -25
- package/handlers/routers/health.js +1 -4
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +10 -2
- package/integrations/index.js +0 -4
- package/integrations/integration-router.js +101 -52
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +34 -3
- package/integrations/repositories/integration-repository-interface.js +17 -1
- package/integrations/repositories/integration-repository-mongo.js +59 -76
- package/integrations/repositories/integration-repository-postgres.js +62 -76
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +5 -1
- package/integrations/use-cases/integration-operations-for-user.js +55 -24
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +1 -15
- package/management-api/management-api-config.js +3 -0
- package/management-api/openapi/components.js +46 -1
- package/management-api/openapi/v2-operations.js +14 -1
- package/management-api/v2/dependencies.js +58 -14
- package/management-api/v2/dto.js +17 -0
- package/management-api/v2/handlers/authorization.js +1 -0
- package/management-api/v2/handlers/entities.js +10 -7
- package/management-api/v2/handlers/integrations.js +6 -3
- package/management-api/v2/request.js +32 -1
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/repositories/module-repository-documentdb.js +41 -1
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +64 -73
- package/modules/repositories/module-repository-postgres.js +63 -73
- package/modules/scope-config.js +177 -0
- package/modules/scope-policy.js +96 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +212 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +59 -0
- package/modules/use-cases/entity-operations-for-user.js +50 -13
- package/modules/use-cases/entity-types.js +62 -14
- package/modules/use-cases/execute-entity-proxy-request.js +15 -0
- package/modules/use-cases/get-entities-for-user.js +16 -5
- package/modules/use-cases/get-entity-module-for-user.js +23 -3
- package/modules/use-cases/get-entity-options-by-id.js +5 -2
- package/modules/use-cases/get-module.js +5 -2
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +36 -12
- package/modules/use-cases/process-authorization-callback.js +52 -18
- package/modules/use-cases/refresh-entity-options.js +5 -2
- package/modules/use-cases/test-module-auth.js +5 -2
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +15 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/schema.prisma +15 -0
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +145 -0
- package/types/user/index.d.ts +7 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/user.js +24 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which stored rows a caller may see and use (ADR-056 §3.4, §3.6).
|
|
3
|
+
*
|
|
4
|
+
* No I/O. One clause list (`ctx.clauses`) is what a repository is asked for and
|
|
5
|
+
* what `describeRecord` checks every returned row against, so a list and an
|
|
6
|
+
* access check cannot drift apart. The clauses are built once per request, when
|
|
7
|
+
* the access context is.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { isPresent, idString, sameId } = require('../user/membership');
|
|
11
|
+
const { AUTH_SOURCES } = require('../user/user');
|
|
12
|
+
const { SCOPES, ACCESS, DEFAULT_ORG_ADMIN_ROLES } = require('./scope-resolution');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One owner/organization pair a repository ORs into a single query.
|
|
16
|
+
* `userId` is the owner's user id, and for an organization-owned row that is
|
|
17
|
+
* the ORGANIZATION user's id, not a person's. `organizationId: null` means "has
|
|
18
|
+
* no organization" (unset or null); otherwise the id of the organization.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} OwnershipClause
|
|
21
|
+
* @property {string} userId
|
|
22
|
+
* @property {(string|null)} organizationId
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether the caller's own rows with no organization belong to the context this
|
|
27
|
+
* request acts in: always when it acts in no organization (today's behaviour),
|
|
28
|
+
* always for an entity under `individualAcross: 'all'`, and otherwise only in
|
|
29
|
+
* the person's legacy organization.
|
|
30
|
+
*/
|
|
31
|
+
function unscopedRowsBelongHere(base, kind) {
|
|
32
|
+
return (
|
|
33
|
+
!base.activeOrgId ||
|
|
34
|
+
(kind === 'entity' && base.individualAcross === 'all') ||
|
|
35
|
+
sameId(base.legacyOrgId, base.activeOrgId)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What a caller may see, as owner/organization pairs:
|
|
41
|
+
*
|
|
42
|
+
* - the active organization's own rows (owner = the organization), whether
|
|
43
|
+
* they carry its id or, written before scoping, none;
|
|
44
|
+
* - the caller's rows in the active organization;
|
|
45
|
+
* - the caller's rows with no organization, when `unscopedRowsBelongHere`.
|
|
46
|
+
*
|
|
47
|
+
* `kind` is 'entity' or 'integration'; only entities may be
|
|
48
|
+
* organization-independent under 'all' (an integration always belongs to the
|
|
49
|
+
* organization it was created in).
|
|
50
|
+
*
|
|
51
|
+
* @returns {OwnershipClause[]}
|
|
52
|
+
*/
|
|
53
|
+
function buildClauses(base, kind) {
|
|
54
|
+
const clauses = [];
|
|
55
|
+
if (base.activeOrgId) {
|
|
56
|
+
clauses.push({ userId: base.activeOrgId, organizationId: base.activeOrgId });
|
|
57
|
+
clauses.push({ userId: base.activeOrgId, organizationId: null });
|
|
58
|
+
}
|
|
59
|
+
if (base.callerId) {
|
|
60
|
+
if (base.activeOrgId) {
|
|
61
|
+
clauses.push({ userId: base.callerId, organizationId: base.activeOrgId });
|
|
62
|
+
}
|
|
63
|
+
if (unscopedRowsBelongHere(base, kind)) {
|
|
64
|
+
clauses.push({ userId: base.callerId, organizationId: null });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return clauses;
|
|
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
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Who the caller is, for ownership and visibility: their individual user, the
|
|
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).
|
|
91
|
+
*
|
|
92
|
+
* @param {Object} user - the authenticated `User` (or a `{ getId }` double)
|
|
93
|
+
* @param {{individualAcross?: string, orgAdminRoles?: string[]}} [options]
|
|
94
|
+
*/
|
|
95
|
+
function accessContext(
|
|
96
|
+
user,
|
|
97
|
+
{ individualAcross = 'organization', orgAdminRoles = DEFAULT_ORG_ADMIN_ROLES } = {}
|
|
98
|
+
) {
|
|
99
|
+
const hasUserModel = typeof user?.getIndividualUser === 'function';
|
|
100
|
+
const individual = hasUserModel ? user.getIndividualUser() : null;
|
|
101
|
+
const base = {
|
|
102
|
+
callerId: hasUserModel ? idString(individual?.id) : idString(user?.getId?.()),
|
|
103
|
+
activeOrgId:
|
|
104
|
+
typeof user?.getActiveOrganizationId === 'function'
|
|
105
|
+
? idString(user.getActiveOrganizationId())
|
|
106
|
+
: null,
|
|
107
|
+
legacyOrgId: idString(individual?.organizationId),
|
|
108
|
+
individualAcross,
|
|
109
|
+
};
|
|
110
|
+
const who = {
|
|
111
|
+
authSource:
|
|
112
|
+
typeof user?.getAuthSource === 'function' ? user.getAuthSource() : null,
|
|
113
|
+
orgRole: typeof user?.getOrgRole === 'function' ? user.getOrgRole() : null,
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
canManageOrg: canManageOrganization(who, orgAdminRoles),
|
|
118
|
+
clauses: {
|
|
119
|
+
entity: buildClauses(base, 'entity'),
|
|
120
|
+
integration: buildClauses(base, 'integration'),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The clauses for `kind` ('entity' | 'integration'): what to ask the repository. */
|
|
126
|
+
const visibilityClauses = (ctx, kind) => ctx.clauses[kind];
|
|
127
|
+
|
|
128
|
+
const matchesClause = (record, clause) =>
|
|
129
|
+
sameId(record.userId, clause.userId) &&
|
|
130
|
+
(clause.organizationId === null
|
|
131
|
+
? !isPresent(record.organizationId)
|
|
132
|
+
: sameId(record.organizationId, clause.organizationId));
|
|
133
|
+
|
|
134
|
+
/** The scope a stored row has for this caller, from its owner. */
|
|
135
|
+
function scopeOf(record, ctx) {
|
|
136
|
+
if (!isPresent(record?.userId)) return SCOPES.GLOBAL;
|
|
137
|
+
if (sameId(record.userId, ctx.activeOrgId)) return SCOPES.ORGANIZATION;
|
|
138
|
+
if (sameId(record.userId, ctx.callerId)) return SCOPES.INDIVIDUAL;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
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
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What a row is to this caller, or null when they may not see it.
|
|
165
|
+
* `{ scope, owner: {type, id}, organizationId, access }` (§3.4). A `global`
|
|
166
|
+
* row is never described: it is not the tenant's to see.
|
|
167
|
+
*/
|
|
168
|
+
function describeRecord(record, ctx, kind) {
|
|
169
|
+
if (!record) return null;
|
|
170
|
+
const scope = scopeOf(record, ctx);
|
|
171
|
+
if (scope === null || scope === SCOPES.GLOBAL) return null;
|
|
172
|
+
if (!ctx.clauses[kind].some((clause) => matchesClause(record, clause))) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
scope,
|
|
177
|
+
owner: { type: scope, id: idString(record.userId) },
|
|
178
|
+
organizationId:
|
|
179
|
+
scope === SCOPES.ORGANIZATION
|
|
180
|
+
? idString(record.userId)
|
|
181
|
+
: idString(record.organizationId),
|
|
182
|
+
access: accessFor(scope, ctx),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `?owner=` values and the scope each one means. */
|
|
187
|
+
const LIST_OWNERS = Object.freeze({
|
|
188
|
+
me: SCOPES.INDIVIDUAL,
|
|
189
|
+
org: SCOPES.ORGANIZATION,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** Whether a described row passes `?scope=` and `?owner=`. */
|
|
193
|
+
function matchesListFilters(description, { scope, owner } = {}) {
|
|
194
|
+
if (scope !== undefined && description.scope !== scope) return false;
|
|
195
|
+
if (owner !== undefined && description.owner.type !== LIST_OWNERS[owner]) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
canManageOrganization,
|
|
203
|
+
accessToOwner,
|
|
204
|
+
accessContext,
|
|
205
|
+
unscopedRowsBelongHere,
|
|
206
|
+
visibilityClauses,
|
|
207
|
+
matchesClause,
|
|
208
|
+
scopeOf,
|
|
209
|
+
describeRecord,
|
|
210
|
+
LIST_OWNERS,
|
|
211
|
+
matchesListFilters,
|
|
212
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test double helper: answers an owner/organization clause the way a database
|
|
3
|
+
* does, so in-memory repositories run the clauses a use case hands them.
|
|
4
|
+
* `clause.organizationId === null` matches a row with no organization (unset
|
|
5
|
+
* or null).
|
|
6
|
+
*/
|
|
7
|
+
const same = (a, b) =>
|
|
8
|
+
a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b);
|
|
9
|
+
|
|
10
|
+
const matchesClause = (row, clause) =>
|
|
11
|
+
same(row.userId, clause.userId) &&
|
|
12
|
+
(clause.organizationId === null
|
|
13
|
+
? row.organizationId === null || row.organizationId === undefined
|
|
14
|
+
: same(row.organizationId, clause.organizationId));
|
|
15
|
+
|
|
16
|
+
const matchesAnyClause = (row, clauses) => clauses.some((clause) => matchesClause(row, clause));
|
|
17
|
+
|
|
18
|
+
module.exports = { same, matchesClause, matchesAnyClause };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorizes an entity for a caller: decides who will own it and in which
|
|
3
|
+
* organization (ADR-056 §3.2, §3.6), then runs the module's authorization.
|
|
4
|
+
* Shared by the v1 `POST /api/authorize` and the v2 authorize flow, so the
|
|
5
|
+
* owner is decided in one place and never by `user.primary`.
|
|
6
|
+
*/
|
|
7
|
+
class AuthorizeEntityForUser {
|
|
8
|
+
/**
|
|
9
|
+
* @param {Object} params
|
|
10
|
+
* @param {Object} params.scopePolicy - from createScopePolicy
|
|
11
|
+
* @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback
|
|
12
|
+
*/
|
|
13
|
+
constructor({ scopePolicy, processAuthorizationCallback }) {
|
|
14
|
+
this.scopePolicy = scopePolicy;
|
|
15
|
+
this.processAuthorizationCallback = processAuthorizationCallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The scope, owner and organization a new entity of `entityType` gets for
|
|
20
|
+
* this caller. Throws a 400/403 when the request cannot have one.
|
|
21
|
+
*
|
|
22
|
+
* @param {{user: Object, entityType: string, scope?: string}} params
|
|
23
|
+
*/
|
|
24
|
+
resolveOwnership({ user, entityType, scope }) {
|
|
25
|
+
return this.scopePolicy.resolveOwnership({
|
|
26
|
+
entityType,
|
|
27
|
+
requestScope: scope,
|
|
28
|
+
user,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {Object} params
|
|
34
|
+
* @param {Object} params.user - authenticated User
|
|
35
|
+
* @param {string} params.entityType
|
|
36
|
+
* @param {Object} params.data - what the module's authorization needs
|
|
37
|
+
* @param {string} [params.scope] - the scope the request asks for
|
|
38
|
+
* @param {Object} [params.ownership] - from `resolveOwnership`, when the
|
|
39
|
+
* caller needed it earlier (a multi-step flow does)
|
|
40
|
+
* @returns {Promise<{result: Object, ownership: Object}>} the callback's
|
|
41
|
+
* `{ credential_id, entity_id, type }` and the ownership it ran with
|
|
42
|
+
*/
|
|
43
|
+
async execute({ user, entityType, data, scope, ownership }) {
|
|
44
|
+
const resolved = ownership ?? this.resolveOwnership({ user, entityType, scope });
|
|
45
|
+
const result = await this.processAuthorizationCallback.execute(
|
|
46
|
+
resolved.ownerId,
|
|
47
|
+
entityType,
|
|
48
|
+
data,
|
|
49
|
+
{
|
|
50
|
+
scope: resolved.scope,
|
|
51
|
+
organizationId: resolved.organizationId,
|
|
52
|
+
legacyOrgId: resolved.legacyOrgId,
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
return { result, ownership: resolved };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { AuthorizeEntityForUser };
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const {
|
|
3
|
+
describeRecord,
|
|
4
|
+
visibilityClauses,
|
|
5
|
+
matchesListFilters,
|
|
6
|
+
} = require('../scope-visibility');
|
|
7
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
8
|
+
const { assertCanManage } = require('./manage-access');
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* Management API v2 entity operations. Each one starts from
|
|
@@ -6,29 +13,55 @@ const Boom = require('@hapi/boom');
|
|
|
6
13
|
* (missing or foreign entity = 404).
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The entities the caller may see in the active organization (ADR-056 §3.4,
|
|
18
|
+
* §3.6): the organization's own, and the caller's individual ones by the
|
|
19
|
+
* visibility rule. Each carries `tenancy` ({scope, owner, organizationId,
|
|
20
|
+
* access}); the stored entity is otherwise untouched.
|
|
21
|
+
*/
|
|
10
22
|
class ListEntitiesForUser {
|
|
11
|
-
constructor({ moduleRepository }) {
|
|
23
|
+
constructor({ moduleRepository, scopePolicy }) {
|
|
12
24
|
this.moduleRepository = moduleRepository;
|
|
25
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
13
26
|
}
|
|
14
27
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
/**
|
|
29
|
+
* @param {Object} user - authenticated User
|
|
30
|
+
* @param {{scope?: string, owner?: string}} [filters] - `?scope=` and `?owner=`
|
|
31
|
+
*/
|
|
32
|
+
async execute(user, filters = {}) {
|
|
33
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
34
|
+
const entities = await this.moduleRepository.findEntitiesByOwnership(
|
|
35
|
+
visibilityClauses(ctx, 'entity')
|
|
19
36
|
);
|
|
20
|
-
|
|
37
|
+
const listed = [];
|
|
38
|
+
for (const entity of Array.isArray(entities) ? entities : []) {
|
|
39
|
+
// The query is the filter; this is the same rule applied again,
|
|
40
|
+
// so a repository that returns too much cannot widen the list.
|
|
41
|
+
const tenancy = describeRecord(entity, ctx, 'entity');
|
|
42
|
+
if (tenancy && matchesListFilters(tenancy, filters)) {
|
|
43
|
+
listed.push({ ...entity, tenancy });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return listed;
|
|
21
47
|
}
|
|
22
48
|
}
|
|
23
49
|
|
|
24
|
-
/** One entity the caller
|
|
50
|
+
/** One entity the caller may use, with its `tenancy`. */
|
|
25
51
|
class GetEntityForUser {
|
|
26
52
|
constructor({ getEntityModuleForUser }) {
|
|
27
53
|
this.getEntityModuleForUser = getEntityModuleForUser;
|
|
28
54
|
}
|
|
29
55
|
|
|
30
56
|
async execute(entityId, user) {
|
|
31
|
-
|
|
57
|
+
const entity = await this.getEntityModuleForUser.loadOwnedEntity(
|
|
58
|
+
entityId,
|
|
59
|
+
user
|
|
60
|
+
);
|
|
61
|
+
return {
|
|
62
|
+
...entity,
|
|
63
|
+
tenancy: this.getEntityModuleForUser.describe(entity, user),
|
|
64
|
+
};
|
|
32
65
|
}
|
|
33
66
|
}
|
|
34
67
|
|
|
@@ -79,10 +112,10 @@ class RefreshEntityOptionsForUser {
|
|
|
79
112
|
}
|
|
80
113
|
|
|
81
114
|
/**
|
|
82
|
-
* Deletes an entity the caller
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* /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.
|
|
86
119
|
*/
|
|
87
120
|
class DeleteEntityForUser {
|
|
88
121
|
constructor({ getEntityModuleForUser, moduleRepository, integrationRepository }) {
|
|
@@ -96,6 +129,10 @@ class DeleteEntityForUser {
|
|
|
96
129
|
entityId,
|
|
97
130
|
user
|
|
98
131
|
);
|
|
132
|
+
assertCanManage(
|
|
133
|
+
this.getEntityModuleForUser.describe(entity, user)?.access,
|
|
134
|
+
`entity ${entity.id}`
|
|
135
|
+
);
|
|
99
136
|
const integrations =
|
|
100
137
|
(await this.integrationRepository.findIntegrationsByEntityId(
|
|
101
138
|
entity.id
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
2
|
const { ModuleConstants } = require('../ModuleConstants');
|
|
3
|
+
const { usedByOf } = require('../entity-type-registry');
|
|
3
4
|
|
|
4
5
|
const AUTH_TYPE_BY_REQUESTER = {
|
|
5
6
|
[ModuleConstants.authType.oauth2]: 'oauth2',
|
|
@@ -22,16 +23,16 @@ function getAuthStepCount(definition) {
|
|
|
22
23
|
* definition: display metadata, auth type and step count. Never credentials
|
|
23
24
|
* or env values.
|
|
24
25
|
*/
|
|
25
|
-
function describeEntityType(definition) {
|
|
26
|
+
function describeEntityType(definition, typeName = definition.moduleName) {
|
|
26
27
|
const stepCount = getAuthStepCount(definition);
|
|
27
28
|
const display = definition.display || {};
|
|
28
29
|
const description = {
|
|
29
|
-
type:
|
|
30
|
+
type: typeName,
|
|
30
31
|
name:
|
|
31
32
|
callIfFunction(definition, 'getDisplayName') ??
|
|
32
33
|
display.label ??
|
|
33
34
|
display.name ??
|
|
34
|
-
|
|
35
|
+
typeName,
|
|
35
36
|
authType:
|
|
36
37
|
callIfFunction(definition, 'getAuthType') ??
|
|
37
38
|
AUTH_TYPE_BY_REQUESTER[definition.API?.requesterType] ??
|
|
@@ -58,27 +59,74 @@ function findDefinition(moduleDefinitions, entityType) {
|
|
|
58
59
|
return definition;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* With a scope policy the entry also says which scope a new connection gets
|
|
64
|
+
* and which a request may choose (ADR-056 §3.1), so a UI can offer the choice.
|
|
65
|
+
* `global` is never offered: a tenant request cannot create one.
|
|
66
|
+
*/
|
|
67
|
+
const scopeOf = (scopePolicy, name, user) => {
|
|
68
|
+
if (!scopePolicy) return {};
|
|
69
|
+
const { scope, source, allow } = scopePolicy.resolveScope({
|
|
70
|
+
entityType: name,
|
|
71
|
+
user,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
scope: {
|
|
75
|
+
default: scope,
|
|
76
|
+
allow: allow.filter((value) => value !== 'global'),
|
|
77
|
+
source,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const describeEntry = (name, entry, scopePolicy, user) => ({
|
|
83
|
+
// The registry key is the name the API uses, whatever the definition spells.
|
|
84
|
+
...describeEntityType(entry.definition, name),
|
|
85
|
+
usedBy: usedByOf(entry),
|
|
86
|
+
...scopeOf(scopePolicy, name, user),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function requireRegistry(entityTypeRegistry) {
|
|
90
|
+
if (!(entityTypeRegistry instanceof Map)) {
|
|
91
|
+
throw new TypeError('entityTypeRegistry is required (see getEntityTypeRegistry)');
|
|
92
|
+
}
|
|
93
|
+
return entityTypeRegistry;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Lists the entity types this app can authorize: registry entries plus every
|
|
98
|
+
* module an integration binds (ADR-059). `usedBy` names the integrations that
|
|
99
|
+
* bind each type; it is empty for a registry-only type.
|
|
100
|
+
*/
|
|
62
101
|
class ListEntityTypes {
|
|
63
|
-
constructor({
|
|
64
|
-
this.
|
|
102
|
+
constructor({ entityTypeRegistry, scopePolicy }) {
|
|
103
|
+
this.registry = requireRegistry(entityTypeRegistry);
|
|
104
|
+
this.scopePolicy = scopePolicy;
|
|
65
105
|
}
|
|
66
106
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
/** @param {{user?: Object}} [params] - the caller, for the level-6 scope default */
|
|
108
|
+
async execute({ user } = {}) {
|
|
109
|
+
return [...this.registry]
|
|
110
|
+
.map(([name, entry]) => describeEntry(name, entry, this.scopePolicy, user))
|
|
70
111
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
71
112
|
}
|
|
72
113
|
}
|
|
73
114
|
|
|
74
|
-
/** Describes one entity type
|
|
115
|
+
/** Describes one entity type, looked up by its registry key; 404 when absent. */
|
|
75
116
|
class GetEntityType {
|
|
76
|
-
constructor({
|
|
77
|
-
this.
|
|
117
|
+
constructor({ entityTypeRegistry, scopePolicy }) {
|
|
118
|
+
this.registry = requireRegistry(entityTypeRegistry);
|
|
119
|
+
this.scopePolicy = scopePolicy;
|
|
78
120
|
}
|
|
79
121
|
|
|
80
|
-
async execute(entityType) {
|
|
81
|
-
|
|
122
|
+
async execute(entityType, { user } = {}) {
|
|
123
|
+
const entry = this.registry.get(entityType);
|
|
124
|
+
if (!entry) {
|
|
125
|
+
throw Boom.notFound(`Entity type '${entityType}' not found`, {
|
|
126
|
+
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return describeEntry(entityType, entry, this.scopePolicy, user);
|
|
82
130
|
}
|
|
83
131
|
}
|
|
84
132
|
|
|
@@ -98,9 +98,24 @@ class ExecuteEntityProxyRequest {
|
|
|
98
98
|
audit.requestBytes = validated.requestBytes;
|
|
99
99
|
const path = normalizeProxyPath(validated.path);
|
|
100
100
|
|
|
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.
|
|
101
104
|
const { entity, definition, module } =
|
|
102
105
|
await this.getEntityModuleForUser.execute(entityId, user);
|
|
103
106
|
audit.module = definition.moduleName;
|
|
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
|
+
}
|
|
104
119
|
const credentialRef = entity.credential;
|
|
105
120
|
const credentialId =
|
|
106
121
|
credentialRef && typeof credentialRef === 'object'
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
2
|
const { mapModuleClassToModuleDTO } = require('../utils/map-module-dto');
|
|
3
|
+
const { describeRecord, visibilityClauses } = require('../scope-visibility');
|
|
4
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
3
5
|
|
|
4
6
|
class GetEntitiesForUser {
|
|
5
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
7
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
8
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
6
9
|
this.moduleRepository = moduleRepository;
|
|
7
10
|
|
|
8
11
|
this.definitionMap = new Map();
|
|
@@ -11,16 +14,24 @@ class GetEntitiesForUser {
|
|
|
11
14
|
}
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The entities a `User` may see in the active organization (ADR-056 §3.6).
|
|
19
|
+
* @param {Object} user - authenticated User
|
|
20
|
+
*/
|
|
21
|
+
async execute(user) {
|
|
22
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
23
|
+
const found = await this.moduleRepository.findEntitiesByOwnership(
|
|
24
|
+
visibilityClauses(ctx, 'entity')
|
|
25
|
+
);
|
|
26
|
+
const entities = (found || []).filter((entity) =>
|
|
27
|
+
describeRecord(entity, ctx, 'entity')
|
|
17
28
|
);
|
|
18
29
|
|
|
19
30
|
return entities.map((entity) => {
|
|
20
31
|
const definition = this.definitionMap.get(entity.moduleName);
|
|
21
32
|
|
|
22
33
|
const moduleInstance = new Module({
|
|
23
|
-
userId,
|
|
34
|
+
userId: entity.userId,
|
|
24
35
|
definition: definition,
|
|
25
36
|
entity: entity,
|
|
26
37
|
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { describeRecord } = require('../scope-visibility');
|
|
3
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* True when `user` may act on records owned by `ownerId`. Uses the User
|
|
5
7
|
* domain object's rule (individual or organisation user model) when present.
|
|
8
|
+
* This is the owner-only rule: it ignores the organization a record belongs
|
|
9
|
+
* to, so it no longer decides access to an entity or an integration
|
|
10
|
+
* (modules/scope-visibility.js does). Credentials still use it.
|
|
6
11
|
* @param {Object} user - authenticated User (or a plain { getId } double)
|
|
7
12
|
* @param {string|number} ownerId
|
|
8
13
|
*/
|
|
@@ -30,8 +35,11 @@ class GetEntityModuleForUser {
|
|
|
30
35
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
31
36
|
* @param {Array<Object>} params.moduleDefinitions
|
|
32
37
|
* @param {Function} [params.createModule] - builds a Module; injectable for tests.
|
|
38
|
+
* @param {Object} [params.scopePolicy] - from createScopePolicy; with none the
|
|
39
|
+
* app configures nothing (ADR-056 §3.1).
|
|
33
40
|
*/
|
|
34
|
-
constructor({ moduleRepository, moduleDefinitions, createModule }) {
|
|
41
|
+
constructor({ moduleRepository, moduleDefinitions, createModule, scopePolicy }) {
|
|
42
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
35
43
|
this.moduleRepository = moduleRepository;
|
|
36
44
|
this.moduleDefinitions = moduleDefinitions || [];
|
|
37
45
|
this.createModule = createModule || defaultCreateModule;
|
|
@@ -61,7 +69,11 @@ class GetEntityModuleForUser {
|
|
|
61
69
|
return { entity, definition, module };
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* The entity record, or a 404 when it is missing or the caller may not use
|
|
74
|
+
* it: not theirs, or paired with another organization (ADR-056 §3.6). A
|
|
75
|
+
* `global` entity is never the tenant's to load.
|
|
76
|
+
*/
|
|
65
77
|
async loadOwnedEntity(entityId, user) {
|
|
66
78
|
let entity = null;
|
|
67
79
|
try {
|
|
@@ -74,13 +86,21 @@ class GetEntityModuleForUser {
|
|
|
74
86
|
throw error;
|
|
75
87
|
}
|
|
76
88
|
}
|
|
77
|
-
if (!entity || !
|
|
89
|
+
if (!entity || !this.describe(entity, user)) {
|
|
78
90
|
throw Boom.notFound(`Entity ${entityId} not found`, {
|
|
79
91
|
code: 'ENTITY_NOT_FOUND',
|
|
80
92
|
});
|
|
81
93
|
}
|
|
82
94
|
return entity;
|
|
83
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `{ scope, owner, organizationId, access }` of an entity to this caller,
|
|
99
|
+
* or null when they may not see it (§3.4).
|
|
100
|
+
*/
|
|
101
|
+
describe(entity, user) {
|
|
102
|
+
return describeRecord(entity, this.scopePolicy.accessContext(user), 'entity');
|
|
103
|
+
}
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
module.exports = { GetEntityModuleForUser, userOwns };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class GetEntityOptionsById {
|
|
4
5
|
/**
|
|
@@ -6,9 +7,11 @@ class GetEntityOptionsById {
|
|
|
6
7
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
7
8
|
* @param {} params.moduleDefinitions
|
|
8
9
|
*/
|
|
9
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
10
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
10
11
|
this.moduleRepository = moduleRepository;
|
|
11
12
|
this.moduleDefinitions = moduleDefinitions;
|
|
13
|
+
// Who may load an entity follows its scope (ADR-056 §3.6).
|
|
14
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -35,7 +38,7 @@ class GetEntityOptionsById {
|
|
|
35
38
|
|
|
36
39
|
// Validate entity ownership
|
|
37
40
|
const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
|
|
38
|
-
?
|
|
41
|
+
? this.scopePolicy.canUseEntity(userIdOrUser, entity)
|
|
39
42
|
: entity.userId?.toString() === userId?.toString();
|
|
40
43
|
|
|
41
44
|
if (!isOwned) {
|