@friggframework/core 2.0.0--canary.685.022c3c3.0 → 2.0.0--canary.683.8a84ab1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +0 -23
- package/README.md +1 -1
- package/credential/use-cases/credential-operations-for-user.js +33 -110
- package/credential/use-cases/reauthorize-credential-for-user.js +13 -10
- package/handlers/app-definition-loader.js +0 -9
- package/index.js +3 -4
- package/integrations/index.js +5 -0
- package/integrations/integration-router.js +26 -28
- package/integrations/repositories/integration-repository-documentdb.js +2 -1
- package/integrations/utils/map-integration-dto.js +44 -1
- package/management-api/management-api-config.js +4 -70
- package/management-api/openapi/components.js +0 -1
- package/management-api/openapi/v2-operations.js +3 -6
- package/management-api/v2/dependencies.js +2 -2
- package/management-api/v2/dto.js +0 -3
- package/management-api/v2/handlers/credentials.js +2 -6
- package/modules/repositories/module-repository-documentdb.js +8 -6
- package/modules/repositories/module-repository-mongo.js +1 -2
- package/modules/repositories/module-repository-postgres.js +1 -2
- package/modules/repositories/module-repository.js +1 -2
- package/modules/scope-config.js +16 -1
- package/modules/scope-policy.js +3 -17
- package/modules/scope-visibility.js +37 -18
- package/modules/use-cases/authorize-entity-for-user.js +2 -4
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/execute-entity-proxy-request.js +18 -50
- package/modules/use-cases/get-entity-module-for-user.js +2 -9
- package/modules/use-cases/get-entity-options-by-id.js +4 -1
- package/modules/use-cases/get-module.js +4 -1
- package/modules/use-cases/multi-step-authorization.js +1 -4
- package/modules/use-cases/refresh-entity-options.js +4 -1
- package/modules/use-cases/test-module-auth.js +4 -1
- package/package.json +5 -5
- package/types/modules/index.d.ts +9 -41
- package/types/user/index.d.ts +14 -4
- package/user/use-cases/get-user-from-adopter-jwt.js +17 -7
- package/user/user.js +17 -4
- package/modules/repositories/entity-not-found.js +0 -18
package/CLAUDE.md
CHANGED
|
@@ -502,29 +502,6 @@ 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
|
-
|
|
528
505
|
### 8. Error Handling (`/errors`)
|
|
529
506
|
|
|
530
507
|
**Purpose**: Standardized error types with proper HTTP semantics.
|
package/README.md
CHANGED
|
@@ -393,7 +393,7 @@ value that is not a three-part JWT is not offered to the verifier at all; it fal
|
|
|
393
393
|
| `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
|
|
394
394
|
| `userIdClaim` | `sub` | App user id (required in the token). |
|
|
395
395
|
| `orgIdClaim` | `org_id` | App organization id (optional). |
|
|
396
|
-
| `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none).
|
|
396
|
+
| `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). For permissions the token's role wins; a token with none uses the role stored on the person's membership of the active organization, and is a member when there is none: it cannot create or change organization-level entities or integrations. `user.orgAdminRoles` (default `['ADMIN']`) names the roles that can. Frigg bearer tokens are not gated unless `user.orgAdminGateBearerTokens: true`. |
|
|
397
397
|
|
|
398
398
|
Rules worth knowing:
|
|
399
399
|
|
|
@@ -1,28 +1,12 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const {
|
|
3
|
-
|
|
4
|
-
describeRecord,
|
|
5
|
-
visibilityClauses,
|
|
6
|
-
matchesListFilters,
|
|
7
|
-
SCOPES,
|
|
8
|
-
} = require('../../modules/entity-scope');
|
|
2
|
+
const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
|
|
3
|
+
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
9
4
|
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
10
5
|
|
|
11
6
|
/**
|
|
12
|
-
* Management API v2 credential operations
|
|
13
|
-
*
|
|
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.
|
|
7
|
+
* Management API v2 credential operations. A credential has no module name
|
|
8
|
+
* of its own; its `type` and the entities that use it come from the
|
|
9
|
+
* caller's entities. A missing or foreign credential is a 404.
|
|
26
10
|
*/
|
|
27
11
|
|
|
28
12
|
const credentialIdOf = (entity) => {
|
|
@@ -55,82 +39,31 @@ function withEntityUsage(credentials, entities) {
|
|
|
55
39
|
});
|
|
56
40
|
}
|
|
57
41
|
|
|
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
|
-
*/
|
|
42
|
+
/** The caller's credentials with their type and entity usage. */
|
|
88
43
|
class ListCredentialsForUser {
|
|
89
|
-
constructor({ credentialRepository, moduleRepository
|
|
44
|
+
constructor({ credentialRepository, moduleRepository }) {
|
|
90
45
|
this.credentialRepository = credentialRepository;
|
|
91
46
|
this.moduleRepository = moduleRepository;
|
|
92
|
-
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
93
47
|
}
|
|
94
48
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
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))),
|
|
49
|
+
async execute(user) {
|
|
50
|
+
const userId = user.getId();
|
|
51
|
+
const [credentials, entities] = await Promise.all([
|
|
52
|
+
this.credentialRepository.findCredentialsByUserId(userId),
|
|
53
|
+
this.moduleRepository.findEntitiesByUserId(userId),
|
|
105
54
|
]);
|
|
106
|
-
|
|
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;
|
|
55
|
+
return withEntityUsage(credentials || [], entities);
|
|
118
56
|
}
|
|
119
57
|
}
|
|
120
58
|
|
|
121
|
-
/** One credential the caller
|
|
59
|
+
/** One credential the caller owns, with its type and entity usage. */
|
|
122
60
|
class GetCredentialForUserV2 {
|
|
123
|
-
constructor({ credentialRepository, moduleRepository
|
|
61
|
+
constructor({ credentialRepository, moduleRepository }) {
|
|
124
62
|
this.credentialRepository = credentialRepository;
|
|
125
63
|
this.moduleRepository = moduleRepository;
|
|
126
|
-
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
127
64
|
}
|
|
128
65
|
|
|
129
66
|
async execute(credentialId, user) {
|
|
130
|
-
const notFound = () =>
|
|
131
|
-
Boom.notFound(`Credential ${credentialId} not found`, {
|
|
132
|
-
code: 'CREDENTIAL_NOT_FOUND',
|
|
133
|
-
});
|
|
134
67
|
let credential = null;
|
|
135
68
|
try {
|
|
136
69
|
credential = await this.credentialRepository.findCredentialById(
|
|
@@ -141,32 +74,26 @@ class GetCredentialForUserV2 {
|
|
|
141
74
|
throw error;
|
|
142
75
|
}
|
|
143
76
|
}
|
|
144
|
-
if (!credential
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return { ...withEntityUsage([credential], visible)[0], tenancy };
|
|
77
|
+
if (!credential || !userOwns(user, credential.userId)) {
|
|
78
|
+
throw Boom.notFound(`Credential ${credentialId} not found`, {
|
|
79
|
+
code: 'CREDENTIAL_NOT_FOUND',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const entities = await this.moduleRepository.findEntitiesByUserId(
|
|
83
|
+
credential.userId
|
|
84
|
+
);
|
|
85
|
+
return withEntityUsage([credential], entities)[0];
|
|
154
86
|
}
|
|
155
87
|
}
|
|
156
88
|
|
|
157
89
|
/**
|
|
158
|
-
* Deletes a credential the caller
|
|
159
|
-
*
|
|
90
|
+
* Deletes a credential the caller owns. The entities that used it stay, with
|
|
91
|
+
* their credential unset, so they show as needing re-authorization rather
|
|
160
92
|
* 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.
|
|
167
93
|
*/
|
|
168
94
|
class DeleteCredentialForUser {
|
|
169
|
-
constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
|
|
95
|
+
constructor({ credentialRepository, moduleRepository, getCredentialForUser, scopePolicy }) {
|
|
96
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
170
97
|
this.credentialRepository = credentialRepository;
|
|
171
98
|
this.moduleRepository = moduleRepository;
|
|
172
99
|
this.getCredentialForUser = getCredentialForUser;
|
|
@@ -177,15 +104,11 @@ class DeleteCredentialForUser {
|
|
|
177
104
|
credentialId,
|
|
178
105
|
user
|
|
179
106
|
);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
`Credential ${credential.id} is still in use by other connections and cannot be deleted from here`,
|
|
186
|
-
{ code: 'CREDENTIAL_SHARED' }
|
|
187
|
-
);
|
|
188
|
-
}
|
|
107
|
+
// A credential an organization owns: the organization's admins only.
|
|
108
|
+
// (null: not the person's or the active organization's, which the
|
|
109
|
+
// owner check above already decided.)
|
|
110
|
+
const access = this.scopePolicy.accessToOwnedBy(user, credential.userId);
|
|
111
|
+
if (access !== null) assertCanManage(access, `credential ${credential.id}`);
|
|
189
112
|
for (const entityId of credential.entityIds) {
|
|
190
113
|
await this.moduleRepository.unsetCredential(entityId);
|
|
191
114
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
2
3
|
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -18,9 +19,7 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
|
|
|
18
19
|
return {
|
|
19
20
|
entityType: credential.type,
|
|
20
21
|
credentialId: String(credential.id),
|
|
21
|
-
|
|
22
|
-
fixedScope: credential.tenancy?.scope,
|
|
23
|
-
access: credential.tenancy?.access,
|
|
22
|
+
ownerId: credential.userId,
|
|
24
23
|
};
|
|
25
24
|
}
|
|
26
25
|
|
|
@@ -32,12 +31,14 @@ class GetCredentialReauthorization {
|
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
|
|
35
|
-
const {
|
|
36
|
-
|
|
34
|
+
const { ownerId, ...binding } = await credentialEntityType(
|
|
35
|
+
this.getCredentialForUser,
|
|
36
|
+
credentialId,
|
|
37
|
+
user
|
|
38
|
+
);
|
|
37
39
|
return this.getAuthorizationStep.execute({
|
|
38
40
|
user,
|
|
39
|
-
|
|
40
|
-
credentialId: boundCredentialId,
|
|
41
|
+
...binding,
|
|
41
42
|
step,
|
|
42
43
|
sessionId,
|
|
43
44
|
state,
|
|
@@ -47,19 +48,21 @@ class GetCredentialReauthorization {
|
|
|
47
48
|
|
|
48
49
|
/** POST /api/v2/credentials/:credentialId/reauthorize */
|
|
49
50
|
class ReauthorizeCredentialForUser {
|
|
50
|
-
constructor({ getCredentialForUser, submitAuthorizationStep }) {
|
|
51
|
+
constructor({ getCredentialForUser, submitAuthorizationStep, scopePolicy }) {
|
|
52
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
51
53
|
this.getCredentialForUser = getCredentialForUser;
|
|
52
54
|
this.submitAuthorizationStep = submitAuthorizationStep;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
|
|
56
|
-
const {
|
|
58
|
+
const { ownerId, ...binding } = await credentialEntityType(
|
|
57
59
|
this.getCredentialForUser,
|
|
58
60
|
credentialId,
|
|
59
61
|
user
|
|
60
62
|
);
|
|
61
63
|
// A credential an organization owns: the organization's admins only.
|
|
62
|
-
|
|
64
|
+
const access = this.scopePolicy.accessToOwnedBy(user, ownerId);
|
|
65
|
+
if (access !== null) assertCanManage(access, `credential ${credentialId}`);
|
|
63
66
|
return this.submitAuthorizationStep.execute({
|
|
64
67
|
user,
|
|
65
68
|
...binding,
|
|
@@ -12,9 +12,6 @@ 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');
|
|
18
15
|
|
|
19
16
|
// Handlers without a database never load the encryption registry, so the
|
|
20
17
|
// credential leaf keys are registered for redaction here too.
|
|
@@ -42,7 +39,6 @@ function registerCredentialLogKeys(appDefinition, moduleDefinitions) {
|
|
|
42
39
|
* @throws {Error} Throws error if index.js file cannot be found in the backend directory.
|
|
43
40
|
* @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig.
|
|
44
41
|
* @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).
|
|
46
42
|
* @example
|
|
47
43
|
* const { integrations, userConfig, telemetry } = loadAppDefinition();
|
|
48
44
|
* console.log(`Found ${integrations.length} integrations`);
|
|
@@ -88,11 +84,6 @@ function loadAppDefinition() {
|
|
|
88
84
|
// individual entity stops the app at load.
|
|
89
85
|
const ownership = resolveOwnershipConfig(appDefinition, entityTypeRegistry);
|
|
90
86
|
|
|
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
|
-
|
|
96
87
|
registerCredentialLogKeys(appDefinition, moduleDefinitions);
|
|
97
88
|
|
|
98
89
|
// Degrade consistently: an invalid telemetry block must never take down a
|
package/index.js
CHANGED
|
@@ -71,6 +71,7 @@ const {
|
|
|
71
71
|
Options,
|
|
72
72
|
createIntegrationRouter,
|
|
73
73
|
checkRequiredParams,
|
|
74
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
74
75
|
LoadIntegrationContextUseCase,
|
|
75
76
|
} = require('./integrations/index');
|
|
76
77
|
const {
|
|
@@ -112,9 +113,6 @@ const {
|
|
|
112
113
|
listBindingScopes,
|
|
113
114
|
OwnershipConfigError,
|
|
114
115
|
} = require('./modules/index');
|
|
115
|
-
const {
|
|
116
|
-
ManagementApiConfigError,
|
|
117
|
-
} = require('./management-api/management-api-config');
|
|
118
116
|
const application = require('./application');
|
|
119
117
|
const utils = require('./utils');
|
|
120
118
|
|
|
@@ -169,6 +167,8 @@ module.exports = {
|
|
|
169
167
|
Options,
|
|
170
168
|
checkRequiredParams,
|
|
171
169
|
createIntegrationRouter,
|
|
170
|
+
// Deprecated (ADR-059): use getEntityTypeRegistry.
|
|
171
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
172
172
|
LoadIntegrationContextUseCase,
|
|
173
173
|
CreateProcess,
|
|
174
174
|
UpdateProcessState,
|
|
@@ -229,7 +229,6 @@ module.exports = {
|
|
|
229
229
|
resolveScope,
|
|
230
230
|
listBindingScopes,
|
|
231
231
|
OwnershipConfigError,
|
|
232
|
-
ManagementApiConfigError,
|
|
233
232
|
// queues
|
|
234
233
|
QueuerUtil,
|
|
235
234
|
|
package/integrations/index.js
CHANGED
|
@@ -4,6 +4,9 @@ const {
|
|
|
4
4
|
createIntegrationRouter,
|
|
5
5
|
checkRequiredParams,
|
|
6
6
|
} = require('./integration-router');
|
|
7
|
+
const {
|
|
8
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
9
|
+
} = require('./utils/map-integration-dto');
|
|
7
10
|
const {
|
|
8
11
|
LoadIntegrationContextUseCase,
|
|
9
12
|
} = require('./use-cases/load-integration-context');
|
|
@@ -18,6 +21,8 @@ module.exports = {
|
|
|
18
21
|
Options,
|
|
19
22
|
createIntegrationRouter,
|
|
20
23
|
checkRequiredParams,
|
|
24
|
+
// Deprecated (ADR-059): a wrapper over getEntityTypeRegistry.
|
|
25
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
21
26
|
LoadIntegrationContextUseCase,
|
|
22
27
|
validateExtensionBinding,
|
|
23
28
|
getExtensionRoutes,
|
|
@@ -65,6 +65,9 @@ const { createScopePolicy } = require('../modules/scope-policy');
|
|
|
65
65
|
const {
|
|
66
66
|
AuthorizeEntityForUser,
|
|
67
67
|
} = require('../modules/use-cases/authorize-entity-for-user');
|
|
68
|
+
const {
|
|
69
|
+
CreateEntityFromCredential,
|
|
70
|
+
} = require('../modules/use-cases/create-entity-from-credential');
|
|
68
71
|
const {
|
|
69
72
|
ResolveIntegrationOwnership,
|
|
70
73
|
} = require('./use-cases/resolve-integration-ownership');
|
|
@@ -199,14 +202,22 @@ function createIntegrationRouter() {
|
|
|
199
202
|
});
|
|
200
203
|
|
|
201
204
|
// The owner of what an authorization creates follows its scope (ADR-056 §3.2).
|
|
205
|
+
const processAuthorizationCallback = new ProcessAuthorizationCallback({
|
|
206
|
+
moduleRepository,
|
|
207
|
+
credentialRepository,
|
|
208
|
+
integrationRepository,
|
|
209
|
+
moduleDefinitions,
|
|
210
|
+
});
|
|
202
211
|
const authorizeEntityForUser = new AuthorizeEntityForUser({
|
|
203
212
|
scopePolicy,
|
|
204
|
-
processAuthorizationCallback
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
processAuthorizationCallback,
|
|
214
|
+
});
|
|
215
|
+
// v1 POST /api/entity: same owner resolution as /api/authorize.
|
|
216
|
+
const createEntityFromCredential = new CreateEntityFromCredential({
|
|
217
|
+
authorizeEntityForUser,
|
|
218
|
+
processAuthorizationCallback,
|
|
219
|
+
credentialRepository,
|
|
220
|
+
moduleDefinitions,
|
|
210
221
|
});
|
|
211
222
|
|
|
212
223
|
const router = express();
|
|
@@ -231,6 +242,7 @@ function createIntegrationRouter() {
|
|
|
231
242
|
getEntityOptionsById,
|
|
232
243
|
refreshEntityOptions,
|
|
233
244
|
authorizeEntityForUser,
|
|
245
|
+
createEntityFromCredential,
|
|
234
246
|
});
|
|
235
247
|
return router;
|
|
236
248
|
}
|
|
@@ -529,6 +541,7 @@ function setEntityRoutes(router, authenticateUser, useCases) {
|
|
|
529
541
|
getEntityOptionsById,
|
|
530
542
|
refreshEntityOptions,
|
|
531
543
|
authorizeEntityForUser,
|
|
544
|
+
createEntityFromCredential,
|
|
532
545
|
} = useCases;
|
|
533
546
|
|
|
534
547
|
router.route('/api/authorize').get(
|
|
@@ -603,35 +616,20 @@ function setEntityRoutes(router, authenticateUser, useCases) {
|
|
|
603
616
|
router.route('/api/entity').post(
|
|
604
617
|
catchAsyncError(async (req, res) => {
|
|
605
618
|
const user = await authenticateUser.execute(req);
|
|
606
|
-
const userId = user.getId();
|
|
607
619
|
const params = checkRequiredParams(req.body, [
|
|
608
620
|
'entityType',
|
|
609
621
|
'data',
|
|
610
622
|
]);
|
|
611
623
|
checkRequiredParams(req.body.data, ['credential_id']);
|
|
612
624
|
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
throw Boom.badRequest('Invalid credential ID');
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const module = await getModuleInstanceFromType.execute(
|
|
624
|
-
userId,
|
|
625
|
-
params.entityType
|
|
626
|
-
);
|
|
627
|
-
const entityDetails = await module.getEntityDetails(
|
|
628
|
-
module.api,
|
|
629
|
-
null,
|
|
630
|
-
null,
|
|
631
|
-
userId
|
|
625
|
+
// The owner follows the entity type's scope, as /api/authorize.
|
|
626
|
+
res.json(
|
|
627
|
+
await createEntityFromCredential.execute({
|
|
628
|
+
user,
|
|
629
|
+
entityType: params.entityType,
|
|
630
|
+
credentialId: params.data.credential_id,
|
|
631
|
+
})
|
|
632
632
|
);
|
|
633
|
-
|
|
634
|
-
res.json(await module.findOrCreateEntity(entityDetails));
|
|
635
633
|
})
|
|
636
634
|
);
|
|
637
635
|
|
|
@@ -40,7 +40,8 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
|
|
|
40
40
|
*/
|
|
41
41
|
async findIntegrationsByOwnership(clauses) {
|
|
42
42
|
if (!Array.isArray(clauses) || clauses.length === 0) return [];
|
|
43
|
-
|
|
43
|
+
// Drained: a list can hold more than the first cursor batch (~101).
|
|
44
|
+
const records = await findManyDrained(this.prisma, 'Integration', {
|
|
44
45
|
$or: clauses.map(({ userId, organizationId }) => ({
|
|
45
46
|
userId: toObjectId(userId),
|
|
46
47
|
organizationId:
|
|
@@ -19,5 +19,48 @@ function mapIntegrationClassToIntegrationDTO(integration) {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
let deprecationLogged = false;
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* @deprecated Since ADR-059. Read `entityTypeRegistry` / `moduleDefinitions`
|
|
26
|
+
* from `loadAppDefinition()`, or call `getEntityTypeRegistry(appDefinition)`
|
|
27
|
+
* and read each entry's `definition`.
|
|
28
|
+
*
|
|
29
|
+
* Kept as a wrapper over the entity type registry so callers that imported it
|
|
30
|
+
* keep working. It returns the registry's definitions, so it follows the
|
|
31
|
+
* registry's rules: one definition per entity type name (the first wins when
|
|
32
|
+
* two integrations bind different definitions under one name, as
|
|
33
|
+
* ModuleFactory already picked), and, when `entities` is passed, registry-only
|
|
34
|
+
* types are included and conflicts throw `EntityRegistryError`.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array<Function>} integrationClasses - integration classes
|
|
37
|
+
* @param {Object} [entities] - `appDefinition.entities` (ADR-059)
|
|
38
|
+
* @returns {Array<Object>} API module definitions
|
|
39
|
+
*/
|
|
40
|
+
function getModulesDefinitionFromIntegrationClasses(
|
|
41
|
+
integrationClasses,
|
|
42
|
+
entities
|
|
43
|
+
) {
|
|
44
|
+
// Required here, not at the top: modules/ must not load at require time
|
|
45
|
+
// of every integration use case that only wants the DTO mapper.
|
|
46
|
+
const {
|
|
47
|
+
getEntityTypeRegistry,
|
|
48
|
+
getEntityTypeDefinitions,
|
|
49
|
+
} = require('../../modules/entity-type-registry');
|
|
50
|
+
if (!deprecationLogged) {
|
|
51
|
+
deprecationLogged = true;
|
|
52
|
+
const { getLogger } = require('../../logs');
|
|
53
|
+
getLogger('frigg.integrations').warn(
|
|
54
|
+
'getModulesDefinitionFromIntegrationClasses is deprecated; use getEntityTypeRegistry (ADR-059)',
|
|
55
|
+
{ eventName: 'frigg.integrations.deprecated_module_definitions' }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return getEntityTypeDefinitions(
|
|
59
|
+
getEntityTypeRegistry({ integrations: integrationClasses, entities })
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
mapIntegrationClassToIntegrationDTO,
|
|
65
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
66
|
+
};
|
|
@@ -9,7 +9,6 @@
|
|
|
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)
|
|
13
12
|
* modules: { // per-module allow-list override
|
|
14
13
|
* hubspot: { allow: 'module' },
|
|
15
14
|
* erp: { allow: [{ method: 'GET', path: '/v1/customers/**' }] },
|
|
@@ -18,36 +17,10 @@
|
|
|
18
17
|
* }
|
|
19
18
|
* ```
|
|
20
19
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* known tenant scopes throws ManagementApiConfigError, and loadAppDefinition
|
|
24
|
-
* calls this so the app stops at load.
|
|
20
|
+
* TODO(F6): `proxy.scopes` (ADR-056 §3.5) is not read yet; see
|
|
21
|
+
* ExecuteEntityProxyRequest.
|
|
25
22
|
*/
|
|
26
23
|
|
|
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
|
-
|
|
51
24
|
// ADR-052 §7. API Gateway HTTP APIs time out at 30s and Lambda responses cap
|
|
52
25
|
// at 6MB, so the timeout and response defaults are also ceilings: an app may
|
|
53
26
|
// lower them, not raise them.
|
|
@@ -74,41 +47,9 @@ function positiveOr(value, fallback, ceiling) {
|
|
|
74
47
|
return isPositiveNumber(value) ? Math.min(value, ceiling) : fallback;
|
|
75
48
|
}
|
|
76
49
|
|
|
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
|
-
|
|
108
50
|
/**
|
|
109
51
|
* @param {Object} [managementApi] - `appDefinition.managementApi`
|
|
110
|
-
* @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number,
|
|
111
|
-
* @throws {ManagementApiConfigError} for an invalid `proxy.scopes`
|
|
52
|
+
* @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, modules: Object } }}
|
|
112
53
|
*/
|
|
113
54
|
function resolveManagementApiConfig(managementApi) {
|
|
114
55
|
const config =
|
|
@@ -133,7 +74,6 @@ function resolveManagementApiConfig(managementApi) {
|
|
|
133
74
|
proxy.maxResponseBytes,
|
|
134
75
|
DEFAULT_PROXY_LIMITS.maxResponseBytes
|
|
135
76
|
),
|
|
136
|
-
scopes: resolveProxyScopes(proxy.scopes),
|
|
137
77
|
modules:
|
|
138
78
|
proxy.modules && typeof proxy.modules === 'object'
|
|
139
79
|
? proxy.modules
|
|
@@ -142,10 +82,4 @@ function resolveManagementApiConfig(managementApi) {
|
|
|
142
82
|
};
|
|
143
83
|
}
|
|
144
84
|
|
|
145
|
-
module.exports = {
|
|
146
|
-
resolveManagementApiConfig,
|
|
147
|
-
DEFAULT_PROXY_LIMITS,
|
|
148
|
-
DEFAULT_PROXY_SCOPES,
|
|
149
|
-
PROXY_SCOPE_VALUES,
|
|
150
|
-
ManagementApiConfigError,
|
|
151
|
-
};
|
|
85
|
+
module.exports = { resolveManagementApiConfig, DEFAULT_PROXY_LIMITS };
|
|
@@ -206,7 +206,6 @@ 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,
|
|
210
209
|
createdAt: nullable({ type: 'string', format: 'date-time' }),
|
|
211
210
|
updatedAt: nullable({ type: 'string', format: 'date-time' }),
|
|
212
211
|
data: {
|