@friggframework/core 2.0.0--canary.683.edb4f0b.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/credential/use-cases/credential-operations-for-user.js +110 -33
- package/credential/use-cases/reauthorize-credential-for-user.js +10 -13
- package/handlers/app-definition-loader.js +9 -0
- package/index.js +4 -0
- 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 -2
- 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-policy.js +15 -0
- package/modules/scope-visibility.js +8 -0
- package/modules/use-cases/authorize-entity-for-user.js +4 -2
- 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/multi-step-authorization.js +4 -1
- package/package.json +5 -5
- package/types/modules/index.d.ts +42 -0
package/CLAUDE.md
CHANGED
|
@@ -502,6 +502,29 @@ The single source of the max receive count is
|
|
|
502
502
|
devtools integration builder uses it for the queue's `RedrivePolicy` and sets
|
|
503
503
|
it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
|
|
504
504
|
|
|
505
|
+
### Management API v2 notes (`/management-api`)
|
|
506
|
+
|
|
507
|
+
- Handlers in `management-api/v2/handlers/` call use cases only; dependencies are wired in
|
|
508
|
+
`management-api/v2/dependencies.js`. Errors are `{ error: { code, message, details? } }`.
|
|
509
|
+
- `appDefinition.managementApi` is resolved by `resolveManagementApiConfig`, which `loadAppDefinition`
|
|
510
|
+
also calls so a bad value stops the app at load. Numeric proxy limits fall back to their defaults;
|
|
511
|
+
`proxy.scopes` (ADR-056 §3.5, default `['individual']`) never does: an unknown scope, `'global'`, a
|
|
512
|
+
non-list, an empty list or a repeated scope throws `ManagementApiConfigError`
|
|
513
|
+
(`MANAGEMENT_API_CONFIG_INVALID`).
|
|
514
|
+
- **Entity proxy** (`ExecuteEntityProxyRequest`): visibility in the active organization comes from
|
|
515
|
+
`GetEntityModuleForUser` (404), then `assertProxyable` reads the same `{ scope, access }` description
|
|
516
|
+
the v2 lists return: `grantsUse(access)` (`use` or `manage`, from `modules/scope-visibility.js`) or 403
|
|
517
|
+
`PROXY_NOT_ALLOWED` `reason: 'access'`, and the scope must be in `proxy.scopes` or 403 `reason: 'scope'`.
|
|
518
|
+
Do not add a second ownership rule for the proxy; change the description instead.
|
|
519
|
+
- **Not visible is 404, and so is missing.** Module repositories throw `entityNotFoundError(id)`
|
|
520
|
+
(`modules/repositories/entity-not-found.js`) for a missing row and use cases match it with
|
|
521
|
+
`isEntityNotFound`. A 500 for a missing id next to a 404 for another tenant's id tells a caller which
|
|
522
|
+
ids exist. A test double that resolves `findEntityById` to `null` hides this: no adapter does that.
|
|
523
|
+
- **Tenant isolation on real PostgreSQL**: `management-api/v2/tenant-isolation.postgres.test.js`, run with
|
|
524
|
+
`FRIGG_TEST_POSTGRES_URL=<a database it may wipe>`. It applies the migrations, truncates every table
|
|
525
|
+
before each test, and drives real adopter-JWT auth through the real router and Prisma repositories.
|
|
526
|
+
Without the variable it is skipped (and says so), not passed. See `docs/guides/entity-scopes.md`.
|
|
527
|
+
|
|
505
528
|
### 8. Error Handling (`/errors`)
|
|
506
529
|
|
|
507
530
|
**Purpose**: Standardized error types with proper HTTP semantics.
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const {
|
|
3
|
-
|
|
2
|
+
const {
|
|
3
|
+
createScopePolicy,
|
|
4
|
+
describeRecord,
|
|
5
|
+
visibilityClauses,
|
|
6
|
+
matchesListFilters,
|
|
7
|
+
SCOPES,
|
|
8
|
+
} = require('../../modules/entity-scope');
|
|
4
9
|
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
|
-
* Management API v2 credential operations. A credential
|
|
8
|
-
*
|
|
9
|
-
*
|
|
12
|
+
* Management API v2 credential operations (ADR-056 §3.4, §3.6). A credential
|
|
13
|
+
* has no module name and no organization of its own:
|
|
14
|
+
*
|
|
15
|
+
* - its `type` and `entityIds` come from the entities that use it that the
|
|
16
|
+
* caller may see, so the same person's account connected in two
|
|
17
|
+
* organizations (one credential, two entities) lists in each organization
|
|
18
|
+
* with that organization's entity only;
|
|
19
|
+
* - who it belongs to is its OWNER (`credential.userId`), never the entities:
|
|
20
|
+
* a credential an individual entity and an organization entity both point at
|
|
21
|
+
* (possible only through legacy data) is described by whoever owns the
|
|
22
|
+
* credential, so the answer cannot change with the order the entities load;
|
|
23
|
+
* - a credential whose entities are all out of sight (another organization's)
|
|
24
|
+
* is a 404, and one with no entities at all (its entity was deleted) is
|
|
25
|
+
* visible to its owner only, as it was before scoping.
|
|
10
26
|
*/
|
|
11
27
|
|
|
12
28
|
const credentialIdOf = (entity) => {
|
|
@@ -39,31 +55,82 @@ function withEntityUsage(credentials, entities) {
|
|
|
39
55
|
});
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* `{scope, owner, organizationId, access}` of a credential for this caller, or
|
|
60
|
+
* null when its owner is neither the caller nor the active organization. Built
|
|
61
|
+
* by `describeRecord` over the row a connection by this owner has: the
|
|
62
|
+
* organization of a visible entity, else the one a new entity would get.
|
|
63
|
+
*/
|
|
64
|
+
function credentialTenancy(credential, visibleEntities, ctx) {
|
|
65
|
+
const entityOrg = visibleEntities.find(
|
|
66
|
+
(entity) => entity.organizationId !== undefined && entity.organizationId !== null
|
|
67
|
+
)?.organizationId;
|
|
68
|
+
const organizationId =
|
|
69
|
+
entityOrg ?? (ctx.individualAcross === 'all' ? null : ctx.activeOrgId);
|
|
70
|
+
return describeRecord({ userId: credential.userId, organizationId }, ctx, 'entity');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The entities of `entities` the caller may see, each with its `tenancy`. */
|
|
74
|
+
function visibleOf(entities, ctx) {
|
|
75
|
+
const visible = [];
|
|
76
|
+
for (const entity of entities || []) {
|
|
77
|
+
const tenancy = describeRecord(entity, ctx, 'entity');
|
|
78
|
+
if (tenancy) visible.push({ ...entity, tenancy });
|
|
79
|
+
}
|
|
80
|
+
return visible;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The caller's credentials with their type, the entities of them they may see
|
|
85
|
+
* and `tenancy`. Credentials with no entity at all are listed too, for the
|
|
86
|
+
* person and the active organization that own them.
|
|
87
|
+
*/
|
|
43
88
|
class ListCredentialsForUser {
|
|
44
|
-
constructor({ credentialRepository, moduleRepository }) {
|
|
89
|
+
constructor({ credentialRepository, moduleRepository, scopePolicy }) {
|
|
45
90
|
this.credentialRepository = credentialRepository;
|
|
46
91
|
this.moduleRepository = moduleRepository;
|
|
92
|
+
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
47
93
|
}
|
|
48
94
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
/** @param {{scope?: string, owner?: string}} [filters] */
|
|
96
|
+
async execute(user, filters = {}) {
|
|
97
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
98
|
+
const owners = [...new Set([ctx.callerId, ctx.activeOrgId].filter(Boolean))];
|
|
99
|
+
const [found, perOwner, owned] = await Promise.all([
|
|
100
|
+
this.moduleRepository.findEntitiesByOwnership(visibilityClauses(ctx, 'entity')),
|
|
101
|
+
Promise.all(owners.map((id) => this.credentialRepository.findCredentialsByUserId(id))),
|
|
102
|
+
// Every entity of those owners, in any organization: only to tell a credential
|
|
103
|
+
// with no entity at all from one used elsewhere.
|
|
104
|
+
Promise.all(owners.map((id) => this.moduleRepository.findEntitiesByUserId(id))),
|
|
54
105
|
]);
|
|
55
|
-
|
|
106
|
+
const visible = visibleOf(found, ctx);
|
|
107
|
+
const usedAnywhere = new Set(owned.flat().map(credentialIdOf).filter(Boolean));
|
|
108
|
+
const listed = [];
|
|
109
|
+
for (const credential of perOwner.flat().filter(Boolean)) {
|
|
110
|
+
const id = String(credential.id);
|
|
111
|
+
const referencing = visible.filter((entity) => credentialIdOf(entity) === id);
|
|
112
|
+
if (referencing.length === 0 && usedAnywhere.has(id)) continue;
|
|
113
|
+
const tenancy = credentialTenancy(credential, referencing, ctx);
|
|
114
|
+
if (!tenancy || !matchesListFilters(tenancy, filters)) continue;
|
|
115
|
+
listed.push({ ...withEntityUsage([credential], referencing)[0], tenancy });
|
|
116
|
+
}
|
|
117
|
+
return listed;
|
|
56
118
|
}
|
|
57
119
|
}
|
|
58
120
|
|
|
59
|
-
/** One credential the caller
|
|
121
|
+
/** One credential the caller may see: see the header. 404 otherwise. */
|
|
60
122
|
class GetCredentialForUserV2 {
|
|
61
|
-
constructor({ credentialRepository, moduleRepository }) {
|
|
123
|
+
constructor({ credentialRepository, moduleRepository, scopePolicy }) {
|
|
62
124
|
this.credentialRepository = credentialRepository;
|
|
63
125
|
this.moduleRepository = moduleRepository;
|
|
126
|
+
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
64
127
|
}
|
|
65
128
|
|
|
66
129
|
async execute(credentialId, user) {
|
|
130
|
+
const notFound = () =>
|
|
131
|
+
Boom.notFound(`Credential ${credentialId} not found`, {
|
|
132
|
+
code: 'CREDENTIAL_NOT_FOUND',
|
|
133
|
+
});
|
|
67
134
|
let credential = null;
|
|
68
135
|
try {
|
|
69
136
|
credential = await this.credentialRepository.findCredentialById(
|
|
@@ -74,26 +141,32 @@ class GetCredentialForUserV2 {
|
|
|
74
141
|
throw error;
|
|
75
142
|
}
|
|
76
143
|
}
|
|
77
|
-
if (!credential
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
);
|
|
85
|
-
|
|
144
|
+
if (!credential) throw notFound();
|
|
145
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
146
|
+
|
|
147
|
+
const referencing =
|
|
148
|
+
(await this.moduleRepository.findEntities({ credentialId: credential.id })) || [];
|
|
149
|
+
const visible = visibleOf(referencing, ctx);
|
|
150
|
+
if (referencing.length > 0 && visible.length === 0) throw notFound();
|
|
151
|
+
const tenancy = credentialTenancy(credential, visible, ctx);
|
|
152
|
+
if (!tenancy) throw notFound();
|
|
153
|
+
return { ...withEntityUsage([credential], visible)[0], tenancy };
|
|
86
154
|
}
|
|
87
155
|
}
|
|
88
156
|
|
|
89
157
|
/**
|
|
90
|
-
* Deletes a credential the caller
|
|
91
|
-
* their credential unset, so they show as needing re-authorization rather
|
|
158
|
+
* Deletes a credential the caller may manage. The entities that used it stay,
|
|
159
|
+
* with their credential unset, so they show as needing re-authorization rather
|
|
92
160
|
* than disappearing from integrations.
|
|
161
|
+
*
|
|
162
|
+
* Refuses with 409 `CREDENTIAL_SHARED` while an entity the caller cannot see
|
|
163
|
+
* still uses it: one credential row serves the same provider account in every
|
|
164
|
+
* organization, and deleting it from one would break the other. The message
|
|
165
|
+
* says nothing about the other connections (not even how many): remove the
|
|
166
|
+
* entities that use it, and it goes with the last one.
|
|
93
167
|
*/
|
|
94
168
|
class DeleteCredentialForUser {
|
|
95
|
-
constructor({ credentialRepository, moduleRepository, getCredentialForUser
|
|
96
|
-
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
169
|
+
constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
|
|
97
170
|
this.credentialRepository = credentialRepository;
|
|
98
171
|
this.moduleRepository = moduleRepository;
|
|
99
172
|
this.getCredentialForUser = getCredentialForUser;
|
|
@@ -104,11 +177,15 @@ class DeleteCredentialForUser {
|
|
|
104
177
|
credentialId,
|
|
105
178
|
user
|
|
106
179
|
);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
180
|
+
assertCanManage(credential.tenancy.access, `credential ${credential.id}`);
|
|
181
|
+
const usedBy =
|
|
182
|
+
(await this.moduleRepository.findEntities({ credentialId: credential.id })) || [];
|
|
183
|
+
if (usedBy.length > credential.entityIds.length) {
|
|
184
|
+
throw Boom.conflict(
|
|
185
|
+
`Credential ${credential.id} is still in use by other connections and cannot be deleted from here`,
|
|
186
|
+
{ code: 'CREDENTIAL_SHARED' }
|
|
187
|
+
);
|
|
188
|
+
}
|
|
112
189
|
for (const entityId of credential.entityIds) {
|
|
113
190
|
await this.moduleRepository.unsetCredential(entityId);
|
|
114
191
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
3
2
|
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -19,7 +18,9 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
|
|
|
19
18
|
return {
|
|
20
19
|
entityType: credential.type,
|
|
21
20
|
credentialId: String(credential.id),
|
|
22
|
-
|
|
21
|
+
// A re-authorization keeps the scope the connection already has.
|
|
22
|
+
fixedScope: credential.tenancy?.scope,
|
|
23
|
+
access: credential.tenancy?.access,
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -31,14 +32,12 @@ class GetCredentialReauthorization {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
|
|
34
|
-
const {
|
|
35
|
-
this.getCredentialForUser,
|
|
36
|
-
credentialId,
|
|
37
|
-
user
|
|
38
|
-
);
|
|
35
|
+
const { entityType, credentialId: boundCredentialId } =
|
|
36
|
+
await credentialEntityType(this.getCredentialForUser, credentialId, user);
|
|
39
37
|
return this.getAuthorizationStep.execute({
|
|
40
38
|
user,
|
|
41
|
-
|
|
39
|
+
entityType,
|
|
40
|
+
credentialId: boundCredentialId,
|
|
42
41
|
step,
|
|
43
42
|
sessionId,
|
|
44
43
|
state,
|
|
@@ -48,21 +47,19 @@ class GetCredentialReauthorization {
|
|
|
48
47
|
|
|
49
48
|
/** POST /api/v2/credentials/:credentialId/reauthorize */
|
|
50
49
|
class ReauthorizeCredentialForUser {
|
|
51
|
-
constructor({ getCredentialForUser, submitAuthorizationStep
|
|
52
|
-
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
50
|
+
constructor({ getCredentialForUser, submitAuthorizationStep }) {
|
|
53
51
|
this.getCredentialForUser = getCredentialForUser;
|
|
54
52
|
this.submitAuthorizationStep = submitAuthorizationStep;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
|
|
58
|
-
const {
|
|
56
|
+
const { access, ...binding } = await credentialEntityType(
|
|
59
57
|
this.getCredentialForUser,
|
|
60
58
|
credentialId,
|
|
61
59
|
user
|
|
62
60
|
);
|
|
63
61
|
// A credential an organization owns: the organization's admins only.
|
|
64
|
-
|
|
65
|
-
if (access !== null) assertCanManage(access, `credential ${credentialId}`);
|
|
62
|
+
assertCanManage(access, `credential ${credentialId}`);
|
|
66
63
|
return this.submitAuthorizationStep.execute({
|
|
67
64
|
user,
|
|
68
65
|
...binding,
|
|
@@ -12,6 +12,9 @@ const {
|
|
|
12
12
|
getEntityTypeDefinitions,
|
|
13
13
|
} = require('../modules/entity-type-registry');
|
|
14
14
|
const { resolveOwnershipConfig } = require('../modules/entity-scope');
|
|
15
|
+
const {
|
|
16
|
+
resolveManagementApiConfig,
|
|
17
|
+
} = require('../management-api/management-api-config');
|
|
15
18
|
|
|
16
19
|
// Handlers without a database never load the encryption registry, so the
|
|
17
20
|
// credential leaf keys are registered for redaction here too.
|
|
@@ -39,6 +42,7 @@ function registerCredentialLogKeys(appDefinition, moduleDefinitions) {
|
|
|
39
42
|
* @throws {Error} Throws error if index.js file cannot be found in the backend directory.
|
|
40
43
|
* @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig.
|
|
41
44
|
* @throws {OwnershipConfigError} Throws if an ownership or scope setting is invalid (ADR-056 §3.1).
|
|
45
|
+
* @throws {ManagementApiConfigError} Throws if `managementApi.proxy.scopes` is invalid (ADR-056 §3.5).
|
|
42
46
|
* @example
|
|
43
47
|
* const { integrations, userConfig, telemetry } = loadAppDefinition();
|
|
44
48
|
* console.log(`Found ${integrations.length} integrations`);
|
|
@@ -84,6 +88,11 @@ function loadAppDefinition() {
|
|
|
84
88
|
// individual entity stops the app at load.
|
|
85
89
|
const ownership = resolveOwnershipConfig(appDefinition, entityTypeRegistry);
|
|
86
90
|
|
|
91
|
+
// And for the Management API: `managementApi.proxy.scopes` decides whose
|
|
92
|
+
// credentials the entity proxy may act through (ADR-056 §3.5), so an
|
|
93
|
+
// unknown scope stops the app here rather than being read as a default.
|
|
94
|
+
resolveManagementApiConfig(managementApi);
|
|
95
|
+
|
|
87
96
|
registerCredentialLogKeys(appDefinition, moduleDefinitions);
|
|
88
97
|
|
|
89
98
|
// Degrade consistently: an invalid telemetry block must never take down a
|
package/index.js
CHANGED
|
@@ -112,6 +112,9 @@ const {
|
|
|
112
112
|
listBindingScopes,
|
|
113
113
|
OwnershipConfigError,
|
|
114
114
|
} = require('./modules/index');
|
|
115
|
+
const {
|
|
116
|
+
ManagementApiConfigError,
|
|
117
|
+
} = require('./management-api/management-api-config');
|
|
115
118
|
const application = require('./application');
|
|
116
119
|
const utils = require('./utils');
|
|
117
120
|
|
|
@@ -226,6 +229,7 @@ module.exports = {
|
|
|
226
229
|
resolveScope,
|
|
227
230
|
listBindingScopes,
|
|
228
231
|
OwnershipConfigError,
|
|
232
|
+
ManagementApiConfigError,
|
|
229
233
|
// queues
|
|
230
234
|
QueuerUtil,
|
|
231
235
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* timeoutMs: 25000, // whole upstream call, lower only
|
|
10
10
|
* maxRequestBytes: 1048576,
|
|
11
11
|
* maxResponseBytes: 5242880, // lower only
|
|
12
|
+
* scopes: ['individual'], // entity scopes the proxy accepts (ADR-056 §3.5)
|
|
12
13
|
* modules: { // per-module allow-list override
|
|
13
14
|
* hubspot: { allow: 'module' },
|
|
14
15
|
* erp: { allow: [{ method: 'GET', path: '/v1/customers/**' }] },
|
|
@@ -17,10 +18,36 @@
|
|
|
17
18
|
* }
|
|
18
19
|
* ```
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* Limits that are out of range fall back to their defaults. `proxy.scopes` is a
|
|
22
|
+
* security setting, so it does not fall back: a value that is not a list of
|
|
23
|
+
* known tenant scopes throws ManagementApiConfigError, and loadAppDefinition
|
|
24
|
+
* calls this so the app stops at load.
|
|
22
25
|
*/
|
|
23
26
|
|
|
27
|
+
const { BaseError } = require('../errors/base-error');
|
|
28
|
+
const { SCOPES } = require('../modules/scope-resolution');
|
|
29
|
+
|
|
30
|
+
/** Raised for an invalid `appDefinition.managementApi` setting. */
|
|
31
|
+
class ManagementApiConfigError extends BaseError {
|
|
32
|
+
constructor(message, options) {
|
|
33
|
+
super(message, options);
|
|
34
|
+
this.code = 'MANAGEMENT_API_CONFIG_INVALID';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The scopes `managementApi.proxy.scopes` may list. `global` is not one of
|
|
40
|
+
* them: a global entity is the app owner's and is never a tenant's to proxy.
|
|
41
|
+
*/
|
|
42
|
+
const PROXY_SCOPE_VALUES = Object.freeze([SCOPES.INDIVIDUAL, SCOPES.ORGANIZATION]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Narrowest by default (ADR-056 §3.5): proxying through a shared credential
|
|
46
|
+
* lets any member act on the organization's account, so an app opts in to
|
|
47
|
+
* `organization` explicitly.
|
|
48
|
+
*/
|
|
49
|
+
const DEFAULT_PROXY_SCOPES = Object.freeze([SCOPES.INDIVIDUAL]);
|
|
50
|
+
|
|
24
51
|
// ADR-052 §7. API Gateway HTTP APIs time out at 30s and Lambda responses cap
|
|
25
52
|
// at 6MB, so the timeout and response defaults are also ceilings: an app may
|
|
26
53
|
// lower them, not raise them.
|
|
@@ -47,9 +74,41 @@ function positiveOr(value, fallback, ceiling) {
|
|
|
47
74
|
return isPositiveNumber(value) ? Math.min(value, ceiling) : fallback;
|
|
48
75
|
}
|
|
49
76
|
|
|
77
|
+
const proxyScopesError = (message) =>
|
|
78
|
+
new ManagementApiConfigError(`managementApi.proxy.scopes: ${message}`);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `managementApi.proxy.scopes`: absent means the default; anything else must be
|
|
82
|
+
* a non-empty list of PROXY_SCOPE_VALUES, each once.
|
|
83
|
+
* @returns {ReadonlyArray<string>}
|
|
84
|
+
* @throws {ManagementApiConfigError}
|
|
85
|
+
*/
|
|
86
|
+
function resolveProxyScopes(scopes) {
|
|
87
|
+
if (scopes === undefined) return DEFAULT_PROXY_SCOPES;
|
|
88
|
+
const allowed = PROXY_SCOPE_VALUES.join(', ');
|
|
89
|
+
if (!Array.isArray(scopes) || scopes.length === 0) {
|
|
90
|
+
throw proxyScopesError(`must be a non-empty list drawn from ${allowed}`);
|
|
91
|
+
}
|
|
92
|
+
for (const scope of scopes) {
|
|
93
|
+
if (scope === SCOPES.GLOBAL) {
|
|
94
|
+
throw proxyScopesError(
|
|
95
|
+
"'global' is not allowed: global entities belong to the app owner and are never proxied for a tenant"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!PROXY_SCOPE_VALUES.includes(scope)) {
|
|
99
|
+
throw proxyScopesError(`unknown scope ${JSON.stringify(scope)}; use ${allowed}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (new Set(scopes).size !== scopes.length) {
|
|
103
|
+
throw proxyScopesError('lists a scope more than once');
|
|
104
|
+
}
|
|
105
|
+
return Object.freeze([...scopes]);
|
|
106
|
+
}
|
|
107
|
+
|
|
50
108
|
/**
|
|
51
109
|
* @param {Object} [managementApi] - `appDefinition.managementApi`
|
|
52
|
-
* @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, modules: Object } }}
|
|
110
|
+
* @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, scopes: ReadonlyArray<string>, modules: Object } }}
|
|
111
|
+
* @throws {ManagementApiConfigError} for an invalid `proxy.scopes`
|
|
53
112
|
*/
|
|
54
113
|
function resolveManagementApiConfig(managementApi) {
|
|
55
114
|
const config =
|
|
@@ -74,6 +133,7 @@ function resolveManagementApiConfig(managementApi) {
|
|
|
74
133
|
proxy.maxResponseBytes,
|
|
75
134
|
DEFAULT_PROXY_LIMITS.maxResponseBytes
|
|
76
135
|
),
|
|
136
|
+
scopes: resolveProxyScopes(proxy.scopes),
|
|
77
137
|
modules:
|
|
78
138
|
proxy.modules && typeof proxy.modules === 'object'
|
|
79
139
|
? proxy.modules
|
|
@@ -82,4 +142,10 @@ function resolveManagementApiConfig(managementApi) {
|
|
|
82
142
|
};
|
|
83
143
|
}
|
|
84
144
|
|
|
85
|
-
module.exports = {
|
|
145
|
+
module.exports = {
|
|
146
|
+
resolveManagementApiConfig,
|
|
147
|
+
DEFAULT_PROXY_LIMITS,
|
|
148
|
+
DEFAULT_PROXY_SCOPES,
|
|
149
|
+
PROXY_SCOPE_VALUES,
|
|
150
|
+
ManagementApiConfigError,
|
|
151
|
+
};
|
|
@@ -206,6 +206,7 @@ const SCHEMAS = {
|
|
|
206
206
|
authIsValid: nullable({ type: 'boolean' }),
|
|
207
207
|
entityIds: { type: 'array', items: { type: 'string' } },
|
|
208
208
|
entityCount: { type: 'integer', minimum: 0 },
|
|
209
|
+
...tenancyProperties,
|
|
209
210
|
createdAt: nullable({ type: 'string', format: 'date-time' }),
|
|
210
211
|
updatedAt: nullable({ type: 'string', format: 'date-time' }),
|
|
211
212
|
data: {
|
|
@@ -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. The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
|
|
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. ADR-056 §3.5: the caller needs use access to the entity in the active organization (an entity they cannot see there is 404), and the entity's scope must be in managementApi.proxy.scopes (default individual only; 403 PROXY_NOT_ALLOWED with details.reason 'scope', or 'access' for view-only access). 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', ref('Error')),
|
|
174
|
+
403: ok("PROXY_NOT_ALLOWED: not in the allow-list, or details.reason 'scope' / 'access'", ref('ProxyError')),
|
|
175
|
+
404: ok('Entity not found, or not visible in the active organization', 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,6 +184,9 @@ const V2_OPERATIONS = {
|
|
|
184
184
|
// Credentials
|
|
185
185
|
listCredentials: {
|
|
186
186
|
summary: "List the caller's credentials (masked)",
|
|
187
|
+
description:
|
|
188
|
+
'A credential is listed through the entities that use it that the caller may see, so each organization lists only its own connections; one no entity uses is listed to its owner. Each carries `scope`, `owner`, `organizationId` and `access`, taken from who owns the credential.',
|
|
189
|
+
parameters: listFilters,
|
|
187
190
|
responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
|
|
188
191
|
},
|
|
189
192
|
getCredential: {
|
|
@@ -124,6 +124,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
124
124
|
const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
|
|
125
125
|
credentialRepository,
|
|
126
126
|
moduleRepository,
|
|
127
|
+
scopePolicy,
|
|
127
128
|
});
|
|
128
129
|
const getAuthorizationRequirements = new GetAuthorizationRequirements({
|
|
129
130
|
moduleDefinitions,
|
|
@@ -246,13 +247,13 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
246
247
|
listCredentialsForUser: new credentialOps.ListCredentialsForUser({
|
|
247
248
|
credentialRepository,
|
|
248
249
|
moduleRepository,
|
|
250
|
+
scopePolicy,
|
|
249
251
|
}),
|
|
250
252
|
getCredentialForUser,
|
|
251
253
|
deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
|
|
252
254
|
credentialRepository,
|
|
253
255
|
moduleRepository,
|
|
254
256
|
getCredentialForUser,
|
|
255
|
-
scopePolicy,
|
|
256
257
|
}),
|
|
257
258
|
getCredentialReauthorization: new GetCredentialReauthorization({
|
|
258
259
|
getCredentialForUser,
|
|
@@ -261,7 +262,6 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
261
262
|
reauthorizeCredentialForUser: new ReauthorizeCredentialForUser({
|
|
262
263
|
getCredentialForUser,
|
|
263
264
|
submitAuthorizationStep,
|
|
264
|
-
scopePolicy,
|
|
265
265
|
}),
|
|
266
266
|
|
|
267
267
|
// Authorization (multi-step)
|
package/management-api/v2/dto.js
CHANGED
|
@@ -75,6 +75,8 @@ const CREDENTIAL_METADATA = new Set([
|
|
|
75
75
|
'type',
|
|
76
76
|
'entityIds',
|
|
77
77
|
'status',
|
|
78
|
+
// Added by the credential use cases (ADR-056 §3.4); never provider data.
|
|
79
|
+
'tenancy',
|
|
78
80
|
]);
|
|
79
81
|
|
|
80
82
|
/**
|
|
@@ -126,6 +128,7 @@ function maskCredential(credential) {
|
|
|
126
128
|
entityCount: entityIds.length,
|
|
127
129
|
createdAt: toIso(credential.createdAt),
|
|
128
130
|
updatedAt: toIso(credential.updatedAt),
|
|
131
|
+
...tenancyFields(credential.tenancy),
|
|
129
132
|
data,
|
|
130
133
|
};
|
|
131
134
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { maskCredential } = require('../dto');
|
|
2
|
+
const { parseListFilters } = require('../request');
|
|
2
3
|
|
|
3
4
|
/** Management API v2 credential handlers, keyed by operationId. */
|
|
4
5
|
function createCredentialHandlers(useCases) {
|
|
@@ -6,8 +7,11 @@ function createCredentialHandlers(useCases) {
|
|
|
6
7
|
useCases;
|
|
7
8
|
|
|
8
9
|
return {
|
|
9
|
-
async listCredentials(
|
|
10
|
-
const credentials = await listCredentialsForUser.execute(
|
|
10
|
+
async listCredentials(req, res, user) {
|
|
11
|
+
const credentials = await listCredentialsForUser.execute(
|
|
12
|
+
user,
|
|
13
|
+
parseListFilters(req.query)
|
|
14
|
+
);
|
|
11
15
|
res.json({ credentials: credentials.map(maskCredential) });
|
|
12
16
|
},
|
|
13
17
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error `findEntityById` throws for an id with no row, and the one
|
|
3
|
+
* predicate that recognises it (as integration-not-found.js does for
|
|
4
|
+
* integrations). The adapters build the error here and use cases match it
|
|
5
|
+
* here, so a missing entity can be told from any other failure without
|
|
6
|
+
* widening a regex.
|
|
7
|
+
*
|
|
8
|
+
* Why it matters: a use case that answers 404 for another tenant's entity must
|
|
9
|
+
* answer the same 404 for an id that does not exist. Otherwise the status code
|
|
10
|
+
* says which ids exist (ADR-052 §3, ADR-056 §3.6).
|
|
11
|
+
*/
|
|
12
|
+
const MESSAGE = /^Entity .+ not found$/;
|
|
13
|
+
|
|
14
|
+
const entityNotFoundError = (id) => new Error(`Entity ${id} not found`);
|
|
15
|
+
|
|
16
|
+
const isEntityNotFound = (error) => MESSAGE.test(error?.message || '');
|
|
17
|
+
|
|
18
|
+
module.exports = { entityNotFoundError, isEntityNotFound };
|
|
@@ -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-policy.js
CHANGED
|
@@ -69,6 +69,20 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
69
69
|
};
|
|
70
70
|
};
|
|
71
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
|
+
|
|
72
86
|
/** Whether `user` may load this stored entity for use (same clauses as the list). */
|
|
73
87
|
const canUseEntity = (user, entity) =>
|
|
74
88
|
describeRecord(entity, contextFor(user), 'entity') !== null;
|
|
@@ -86,6 +100,7 @@ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
|
86
100
|
accessContext: contextFor,
|
|
87
101
|
resolveScope: resolveEntityScope,
|
|
88
102
|
resolveOwnership,
|
|
103
|
+
ownershipForScope,
|
|
89
104
|
canUseEntity,
|
|
90
105
|
});
|
|
91
106
|
}
|
|
@@ -148,6 +148,13 @@ const accessFor = (scope, ctx) => {
|
|
|
148
148
|
return ACCESS.USE;
|
|
149
149
|
};
|
|
150
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
|
+
|
|
151
158
|
/**
|
|
152
159
|
* What the caller may do with a record owned by `ownerId` that carries no
|
|
153
160
|
* organization of its own (a credential): manage the person's own, manage the
|
|
@@ -207,6 +214,7 @@ module.exports = {
|
|
|
207
214
|
matchesClause,
|
|
208
215
|
scopeOf,
|
|
209
216
|
describeRecord,
|
|
217
|
+
grantsUse,
|
|
210
218
|
LIST_OWNERS,
|
|
211
219
|
matchesListFilters,
|
|
212
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,
|
|
@@ -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;
|
|
@@ -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
|
}
|
package/types/modules/index.d.ts
CHANGED
|
@@ -68,12 +68,49 @@ declare module "@friggframework/core" {
|
|
|
68
68
|
owner?: "derive" | "individual" | "organization";
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** One operation a module or the app lets the entity proxy forward (ADR-052). */
|
|
72
|
+
export interface ProxyAllowRule {
|
|
73
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "*";
|
|
74
|
+
/** Relative to the module's base URL. `:param` is one segment; a trailing `/**` is a subtree. */
|
|
75
|
+
path: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `appDefinition.managementApi.proxy`: the entity proxy, POST /api/v2/entities/:id/proxy (ADR-052). */
|
|
79
|
+
export interface ManagementApiProxyConfig {
|
|
80
|
+
/** Mount the route. Default false. */
|
|
81
|
+
enable?: boolean;
|
|
82
|
+
/** Upstream timeout in ms; can only be lowered from 25000. */
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
/** Largest request body in bytes; default 1 MiB, at most 10 MiB. */
|
|
85
|
+
maxRequestBytes?: number;
|
|
86
|
+
/** Largest response body in bytes; can only be lowered from 5 MiB. */
|
|
87
|
+
maxResponseBytes?: number;
|
|
88
|
+
/**
|
|
89
|
+
* Entity scopes the proxy accepts (ADR-056 §3.5). Default `['individual']`.
|
|
90
|
+
* Add `'organization'` to let a member with use access proxy through the
|
|
91
|
+
* organization's shared credential. `global` is never accepted. An unknown,
|
|
92
|
+
* empty or repeated value throws ManagementApiConfigError at load.
|
|
93
|
+
*/
|
|
94
|
+
scopes?: Array<Exclude<EntityScope, "global">>;
|
|
95
|
+
/** Per-module allow-list override; `'module'` uses the module's own list. */
|
|
96
|
+
modules?: Record<string, { allow: "module" | ProxyAllowRule[] }>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `appDefinition.managementApi` (ADR-052, ADR-053). */
|
|
100
|
+
export interface ManagementApiConfig {
|
|
101
|
+
/** Serve the deprecated v1 routes. Default true. */
|
|
102
|
+
v1?: boolean;
|
|
103
|
+
proxy?: ManagementApiProxyConfig;
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
export interface AppDefinition {
|
|
72
107
|
integrations: any[];
|
|
73
108
|
/** App-level entity type registry (ADR-059). */
|
|
74
109
|
entities?: AppDefinitionEntities;
|
|
75
110
|
/** Who owns what a person connects (ADR-056). */
|
|
76
111
|
ownership?: AppOwnership;
|
|
112
|
+
/** Management API versions and the entity proxy (ADR-052, ADR-053, ADR-056 §3.5). */
|
|
113
|
+
managementApi?: ManagementApiConfig;
|
|
77
114
|
[key: string]: unknown;
|
|
78
115
|
}
|
|
79
116
|
|
|
@@ -138,6 +175,11 @@ declare module "@friggframework/core" {
|
|
|
138
175
|
appDefinition?: Pick<AppDefinition, "entities" | "integrations">
|
|
139
176
|
): EntityTypeRegistry;
|
|
140
177
|
|
|
178
|
+
/** Invalid `managementApi` setting (today: `proxy.scopes`), thrown at app-definition load. */
|
|
179
|
+
export class ManagementApiConfigError extends Error {
|
|
180
|
+
code: "MANAGEMENT_API_CONFIG_INVALID";
|
|
181
|
+
}
|
|
182
|
+
|
|
141
183
|
/** Invalid `entities` registry. `code` is always `ENTITY_REGISTRY_INVALID`. */
|
|
142
184
|
export class EntityRegistryError extends Error {
|
|
143
185
|
code: "ENTITY_REGISTRY_INVALID";
|