@friggframework/core 2.0.0--canary.681.2df9ef0.0 → 2.0.0--canary.682.cbca9e2.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 +36 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/index.js +6 -2
- 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 +99 -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 +40 -19
- 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 +56 -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 +155 -0
- package/modules/scope-policy.js +80 -0
- package/modules/scope-resolution.js +277 -0
- package/modules/scope-visibility.js +165 -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 +42 -10
- 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/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
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
const {
|
|
3
3
|
mapIntegrationClassToIntegrationDTO,
|
|
4
4
|
} = require('../utils/map-integration-dto');
|
|
5
|
+
const {
|
|
6
|
+
describeRecord,
|
|
7
|
+
visibilityClauses,
|
|
8
|
+
matchesListFilters,
|
|
9
|
+
} = require('../../modules/scope-visibility');
|
|
10
|
+
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
|
-
* Use case for retrieving
|
|
13
|
+
* Use case for retrieving the integrations a caller may see: the ones they own
|
|
14
|
+
* or their active organization owns, created in that organization (ADR-056
|
|
15
|
+
* §3.6), each with `tenancy` ({scope, owner, organizationId, access}).
|
|
8
16
|
* @class GetIntegrationsForUser
|
|
9
17
|
*/
|
|
10
18
|
class GetIntegrationsForUser {
|
|
@@ -15,13 +23,16 @@ class GetIntegrationsForUser {
|
|
|
15
23
|
* @param {Array<import('../integration').Integration>} params.integrationClasses - Array of available integration classes.
|
|
16
24
|
* @param {import('../../modules/module-factory').ModuleFactory} params.moduleFactory - Service for module instantiation and management.
|
|
17
25
|
* @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository - Repository for module and entity data operations.
|
|
26
|
+
* @param {Object} [params.scopePolicy] - from createScopePolicy
|
|
18
27
|
*/
|
|
19
28
|
constructor({
|
|
20
29
|
integrationRepository,
|
|
21
30
|
integrationClasses,
|
|
22
31
|
moduleFactory,
|
|
23
32
|
moduleRepository,
|
|
33
|
+
scopePolicy,
|
|
24
34
|
}) {
|
|
35
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
25
36
|
/**
|
|
26
37
|
* @type {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface}
|
|
27
38
|
*/
|
|
@@ -32,57 +43,86 @@ class GetIntegrationsForUser {
|
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
/**
|
|
35
|
-
* Executes the retrieval of all integrations for a user.
|
|
36
46
|
* @async
|
|
37
|
-
* @param {
|
|
38
|
-
* @
|
|
47
|
+
* @param {Object} user - authenticated User
|
|
48
|
+
* @param {{scope?: string, owner?: string}} [filters] - `?scope=` and `?owner=`
|
|
49
|
+
* @returns {Promise<Object[]>} Array of integration DTOs.
|
|
39
50
|
*/
|
|
40
|
-
async execute(
|
|
41
|
-
const
|
|
42
|
-
|
|
51
|
+
async execute(user, filters = {}) {
|
|
52
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
53
|
+
const records = await this.integrationRepository.findIntegrationsByOwnership(
|
|
54
|
+
visibilityClauses(ctx, 'integration')
|
|
55
|
+
);
|
|
43
56
|
|
|
44
57
|
const integrations = [];
|
|
58
|
+
for (const record of records) {
|
|
59
|
+
// The query is the filter; the same rule again means a repository
|
|
60
|
+
// that returns too much cannot widen the list.
|
|
61
|
+
const tenancy = describeRecord(record, ctx, 'integration');
|
|
62
|
+
if (!tenancy || !matchesListFilters(tenancy, filters)) continue;
|
|
63
|
+
integrations.push({ ...(await this.toDto(record)), tenancy });
|
|
64
|
+
}
|
|
65
|
+
return integrations;
|
|
66
|
+
}
|
|
45
67
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
/** Hydrates one stored integration record into its DTO. */
|
|
69
|
+
async toDto(integrationRecord) {
|
|
70
|
+
const entities = await this.moduleRepository.findEntitiesByIds(
|
|
71
|
+
integrationRecord.entitiesIds
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const integrationClass = this.integrationClasses.find(
|
|
75
|
+
(integrationClass) =>
|
|
76
|
+
integrationClass.Definition.name ===
|
|
77
|
+
integrationRecord.config.type
|
|
78
|
+
);
|
|
50
79
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
80
|
+
const modules = [];
|
|
81
|
+
for (const entity of entities) {
|
|
82
|
+
const moduleInstance = await this.moduleFactory.getModuleInstance(
|
|
83
|
+
entity.id,
|
|
84
|
+
integrationRecord.userId
|
|
55
85
|
);
|
|
86
|
+
modules.push(moduleInstance);
|
|
87
|
+
}
|
|
56
88
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
89
|
+
return mapIntegrationClassToIntegrationDTO({
|
|
90
|
+
id: integrationRecord.id,
|
|
91
|
+
userId: integrationRecord.userId,
|
|
92
|
+
entities: entities,
|
|
93
|
+
config: integrationRecord.config,
|
|
94
|
+
status: integrationRecord.status,
|
|
95
|
+
version: integrationRecord.version,
|
|
96
|
+
messages: integrationRecord.messages || { errors: [], warnings: [] },
|
|
97
|
+
modules,
|
|
98
|
+
options: integrationClass.getOptionDetails(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
66
102
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Every integration one user id owns, with no organization rules: the lookup
|
|
105
|
+
* behind the app-level `friggCommands.findIntegrationsByUserId`, which has a
|
|
106
|
+
* user id and no request.
|
|
107
|
+
*/
|
|
108
|
+
class GetIntegrationsByUserId {
|
|
109
|
+
/** @param {ConstructorParameters<typeof GetIntegrationsForUser>[0]} params */
|
|
110
|
+
constructor(params) {
|
|
111
|
+
this.integrationRepository = params.integrationRepository;
|
|
112
|
+
this.hydrator = new GetIntegrationsForUser(params);
|
|
113
|
+
}
|
|
78
114
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
115
|
+
/** @param {string} userId */
|
|
116
|
+
async execute(userId) {
|
|
117
|
+
const records = await this.integrationRepository.findIntegrationsByUserId(
|
|
118
|
+
userId
|
|
119
|
+
);
|
|
120
|
+
const integrations = [];
|
|
121
|
+
for (const record of records) {
|
|
122
|
+
integrations.push(await this.hydrator.toDto(record));
|
|
82
123
|
}
|
|
83
|
-
|
|
84
124
|
return integrations;
|
|
85
125
|
}
|
|
86
126
|
}
|
|
87
127
|
|
|
88
|
-
module.exports = { GetIntegrationsForUser };
|
|
128
|
+
module.exports = { GetIntegrationsForUser, GetIntegrationsByUserId };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
GetIntegrationsForUser,
|
|
3
|
+
GetIntegrationsByUserId,
|
|
4
|
+
} = require('./get-integrations-for-user');
|
|
2
5
|
const { DeleteIntegrationForUser } = require('./delete-integration-for-user');
|
|
3
6
|
const { CreateIntegration } = require('./create-integration');
|
|
4
7
|
const { GetIntegration } = require('./get-integration');
|
|
@@ -9,6 +12,7 @@ const { GetProcess } = require('./get-process');
|
|
|
9
12
|
|
|
10
13
|
module.exports = {
|
|
11
14
|
GetIntegrationsForUser,
|
|
15
|
+
GetIntegrationsByUserId,
|
|
12
16
|
DeleteIntegrationForUser,
|
|
13
17
|
CreateIntegration,
|
|
14
18
|
GetIntegration,
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const {
|
|
2
|
+
const { describeRecord } = require('../../modules/scope-visibility');
|
|
3
|
+
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
4
|
+
const { isIntegrationNotFound } = require('../repositories/integration-not-found');
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
* Management API v2 integration operations. They put an
|
|
6
|
-
* (missing or
|
|
7
|
-
* integration use cases, and pass the record's owner id on
|
|
8
|
-
*
|
|
7
|
+
* Management API v2 integration operations. They put an access check
|
|
8
|
+
* (missing or unavailable integration = 404) in front of the existing
|
|
9
|
+
* integration use cases, and pass the record's owner id on. Access follows
|
|
10
|
+
* the owner and the organization the integration was created in (ADR-056
|
|
11
|
+
* §3.6), not the owner alone.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
const notFound = (integrationId) =>
|
|
@@ -13,10 +16,20 @@ const notFound = (integrationId) =>
|
|
|
13
16
|
code: 'INTEGRATION_NOT_FOUND',
|
|
14
17
|
});
|
|
15
18
|
|
|
16
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* The integration record, or 404 when it is missing or the caller may not
|
|
21
|
+
* use it: not theirs or their active organization's, or created in another
|
|
22
|
+
* organization.
|
|
23
|
+
*/
|
|
17
24
|
class GetOwnedIntegrationRecord {
|
|
18
|
-
constructor({ integrationRepository }) {
|
|
25
|
+
constructor({ integrationRepository, scopePolicy }) {
|
|
19
26
|
this.integrationRepository = integrationRepository;
|
|
27
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `{ scope, owner, organizationId, access }` of a record to this caller, or null. */
|
|
31
|
+
describe(record, user) {
|
|
32
|
+
return describeRecord(record, this.scopePolicy.accessContext(user), 'integration');
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
async execute(integrationId, user) {
|
|
@@ -26,12 +39,15 @@ class GetOwnedIntegrationRecord {
|
|
|
26
39
|
integrationId
|
|
27
40
|
);
|
|
28
41
|
} catch (error) {
|
|
29
|
-
if (
|
|
42
|
+
if (
|
|
43
|
+
/Invalid ID|ObjectId|Malformed/i.test(error?.message || '') ||
|
|
44
|
+
isIntegrationNotFound(error)
|
|
45
|
+
) {
|
|
30
46
|
throw notFound(integrationId);
|
|
31
47
|
}
|
|
32
48
|
throw error;
|
|
33
49
|
}
|
|
34
|
-
if (!record || !
|
|
50
|
+
if (!record || !this.describe(record, user)) {
|
|
35
51
|
throw notFound(integrationId);
|
|
36
52
|
}
|
|
37
53
|
return record;
|
|
@@ -39,14 +55,15 @@ class GetOwnedIntegrationRecord {
|
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
/**
|
|
42
|
-
* Creates (or reuses) an integration between entities the caller
|
|
43
|
-
*
|
|
44
|
-
* before anything is written
|
|
58
|
+
* Creates (or reuses) an integration between entities the caller may use.
|
|
59
|
+
* The entities' access, the binding scopes and the integration type are
|
|
60
|
+
* checked before anything is written, and the integration is owned and
|
|
61
|
+
* paired with an organization by `resolveIntegrationOwnership` (§3, §3.6).
|
|
45
62
|
*/
|
|
46
63
|
class CreateIntegrationForUser {
|
|
47
|
-
constructor({ createIntegration,
|
|
64
|
+
constructor({ createIntegration, resolveIntegrationOwnership, integrationClasses }) {
|
|
48
65
|
this.createIntegration = createIntegration;
|
|
49
|
-
this.
|
|
66
|
+
this.resolveIntegrationOwnership = resolveIntegrationOwnership;
|
|
50
67
|
this.integrationClasses = integrationClasses || [];
|
|
51
68
|
}
|
|
52
69
|
|
|
@@ -74,13 +91,17 @@ class CreateIntegrationForUser {
|
|
|
74
91
|
details: { field: 'entities' },
|
|
75
92
|
});
|
|
76
93
|
}
|
|
77
|
-
|
|
78
|
-
await this.
|
|
79
|
-
|
|
94
|
+
const { ownerId, organizationId, adoptUnscoped } =
|
|
95
|
+
await this.resolveIntegrationOwnership.execute({
|
|
96
|
+
entityIds: entities,
|
|
97
|
+
config,
|
|
98
|
+
user,
|
|
99
|
+
});
|
|
80
100
|
return this.createIntegration.execute(
|
|
81
101
|
entities.map(String),
|
|
82
|
-
|
|
83
|
-
config
|
|
102
|
+
ownerId,
|
|
103
|
+
config,
|
|
104
|
+
{ organizationId, adoptUnscoped }
|
|
84
105
|
);
|
|
85
106
|
}
|
|
86
107
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { SCOPES, resolveScope } = require('../../modules/scope-resolution');
|
|
3
|
+
const {
|
|
4
|
+
integrationOwnerKind,
|
|
5
|
+
resolveIntegrationOwner,
|
|
6
|
+
} = require('../../modules/scope-resolution');
|
|
7
|
+
const {
|
|
8
|
+
describeRecord,
|
|
9
|
+
unscopedRowsBelongHere,
|
|
10
|
+
} = require('../../modules/scope-visibility');
|
|
11
|
+
const {
|
|
12
|
+
bindingScopeSetting,
|
|
13
|
+
readIntegrationOwnership,
|
|
14
|
+
} = require('../../modules/scope-config');
|
|
15
|
+
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
16
|
+
const { entityTypeNameOf } = require('../../modules/entity-type-registry');
|
|
17
|
+
|
|
18
|
+
const notFound = (entityId) =>
|
|
19
|
+
Boom.notFound(`Entity ${entityId} not found`, { code: 'ENTITY_NOT_FOUND' });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Who owns an integration about to be created, and in which organization
|
|
23
|
+
* (ADR-056 §3, §3.3, §3.6). Shared by the v1 and v2 create paths. Nothing is
|
|
24
|
+
* persisted before this has answered, so a refusal leaves no row behind.
|
|
25
|
+
*
|
|
26
|
+
* Every entity must be one the caller may use in the active organization. A
|
|
27
|
+
* missing one, another organization's and a `global` one are the same 404, so
|
|
28
|
+
* ids cannot be probed. When the integration declares a scope (a binding's
|
|
29
|
+
* `scope` or `Definition.entities[key].scope`, or `Definition.ownership.scope`)
|
|
30
|
+
* the entity's scope must be in it. Levels below the integration (entity type,
|
|
31
|
+
* app, default) decide where a connection is CREATED and do not restrict what
|
|
32
|
+
* an integration may bind, so an app that adds a registry scope later does not
|
|
33
|
+
* break integrations that already use older connections.
|
|
34
|
+
*
|
|
35
|
+
* The owner follows the entities (all organization: the organization;
|
|
36
|
+
* otherwise the person) unless `Definition.ownership.owner` says. Every entity
|
|
37
|
+
* must then be of the owner's own scope: an integration is loaded by its owner,
|
|
38
|
+
* who must own what it uses, so an integration that mixes an organization
|
|
39
|
+
* entity with a person's own, or that names an owner that does not match, is
|
|
40
|
+
* refused with a 400 here, before anything is written.
|
|
41
|
+
*/
|
|
42
|
+
class ResolveIntegrationOwnership {
|
|
43
|
+
/**
|
|
44
|
+
* @param {Object} params
|
|
45
|
+
* @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
46
|
+
* @param {Array<Function>} params.integrationClasses
|
|
47
|
+
* @param {Object} [params.scopePolicy] - from createScopePolicy
|
|
48
|
+
*/
|
|
49
|
+
constructor({ moduleRepository, integrationClasses, scopePolicy }) {
|
|
50
|
+
this.moduleRepository = moduleRepository;
|
|
51
|
+
this.integrationClasses = integrationClasses || [];
|
|
52
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {Object} params
|
|
57
|
+
* @param {Array<string|number>} params.entityIds
|
|
58
|
+
* @param {Object} params.config - `{ type }`
|
|
59
|
+
* @param {Object} params.user - authenticated User
|
|
60
|
+
* @returns {Promise<{ownerId: string, organizationId: (string|null), ownerKind: string, adoptUnscoped: boolean}>}
|
|
61
|
+
*/
|
|
62
|
+
async execute({ entityIds, config, user }) {
|
|
63
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
64
|
+
const integrationClass = this.integrationClasses.find(
|
|
65
|
+
(IntegrationClass) => IntegrationClass.Definition?.name === config?.type
|
|
66
|
+
);
|
|
67
|
+
const ownership = readIntegrationOwnership(integrationClass);
|
|
68
|
+
|
|
69
|
+
const entities = await this.loadEntities(entityIds);
|
|
70
|
+
const scoped = entities.map((entity) => ({
|
|
71
|
+
entity,
|
|
72
|
+
scope: this.acceptedScope({ entity, ctx, integrationClass, ownership }),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const ownerKind = integrationOwnerKind(
|
|
76
|
+
scoped.map(({ scope }) => scope),
|
|
77
|
+
ownership.owner
|
|
78
|
+
);
|
|
79
|
+
this.assertOneScope(scoped, ownerKind, config?.type);
|
|
80
|
+
return {
|
|
81
|
+
...resolveIntegrationOwner(ownerKind, ctx),
|
|
82
|
+
ownerKind,
|
|
83
|
+
// Whether an existing integration with no organization (written
|
|
84
|
+
// before scoping) is this caller's to reuse. An organization-owned
|
|
85
|
+
// one is unambiguous: its owner is the organization, whoever asks
|
|
86
|
+
// and whatever their own legacy pointer says. A person's is theirs
|
|
87
|
+
// only where scope-visibility says (their legacy organization).
|
|
88
|
+
adoptUnscoped:
|
|
89
|
+
ownerKind === SCOPES.ORGANIZATION ||
|
|
90
|
+
unscopedRowsBelongHere(ctx, 'integration'),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every entity must be of the integration owner's own scope (see the class comment). */
|
|
95
|
+
assertOneScope(scoped, ownerKind, type) {
|
|
96
|
+
for (const { entity, scope } of scoped) {
|
|
97
|
+
if (scope === ownerKind) continue;
|
|
98
|
+
// The load-time check (resolveOwnershipConfig) only sees declared
|
|
99
|
+
// scopes; this one sees the entities actually named.
|
|
100
|
+
if (ownerKind === SCOPES.ORGANIZATION) {
|
|
101
|
+
throw Boom.badRequest(
|
|
102
|
+
"An organization-owned integration cannot use an 'individual' entity: a shared integration must not run on one person's credential",
|
|
103
|
+
{
|
|
104
|
+
code: 'ORG_INTEGRATION_INDIVIDUAL_ENTITY',
|
|
105
|
+
details: { entityId: String(entity.id) },
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
throw Boom.badRequest(
|
|
110
|
+
`Integration '${type}' would be owned by the person but uses the organization entity ${entity.id}. An integration must use entities of one scope: all of the person's, or all of the organization's.`,
|
|
111
|
+
{
|
|
112
|
+
code: 'MIXED_ENTITY_SCOPES',
|
|
113
|
+
details: { entityId: String(entity.id), scope },
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async loadEntities(entityIds) {
|
|
120
|
+
const ids = (entityIds || []).map(String);
|
|
121
|
+
if (ids.length === 0) return [];
|
|
122
|
+
let found;
|
|
123
|
+
try {
|
|
124
|
+
found = await this.moduleRepository.findEntitiesByIds(ids);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// A malformed id (e.g. not an integer on PostgreSQL) is "not found".
|
|
127
|
+
if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
|
|
128
|
+
throw notFound(ids[0]);
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
const byId = new Map((found || []).filter(Boolean).map((e) => [String(e.id), e]));
|
|
133
|
+
return ids.map((id) => {
|
|
134
|
+
if (!byId.has(id)) throw notFound(id);
|
|
135
|
+
return byId.get(id);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The entity's scope for this caller, or a refusal. */
|
|
140
|
+
acceptedScope({ entity, ctx, integrationClass, ownership }) {
|
|
141
|
+
const described = describeRecord(entity, ctx, 'entity');
|
|
142
|
+
if (!described) throw notFound(entity.id);
|
|
143
|
+
const { scope } = described;
|
|
144
|
+
|
|
145
|
+
const declared = this.declaredScope({ entity, integrationClass, ownership });
|
|
146
|
+
if (declared && !declared.allow.includes(scope)) {
|
|
147
|
+
throw Boom.badRequest(
|
|
148
|
+
`Entity ${entity.id} is ${scope}-scoped, but this integration binds ${entity.moduleName} at ${declared.allow.join(' or ')} scope`,
|
|
149
|
+
{
|
|
150
|
+
code: 'ENTITY_SCOPE_MISMATCH',
|
|
151
|
+
details: { entityId: String(entity.id), scope, allowed: declared.allow },
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return scope;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What the integration itself declares for this entity's binding (levels 2
|
|
160
|
+
* and 3 only), or null when it declares nothing.
|
|
161
|
+
*/
|
|
162
|
+
declaredScope({ entity, integrationClass, ownership }) {
|
|
163
|
+
const Definition = integrationClass?.Definition;
|
|
164
|
+
const key = Object.keys(Definition?.modules || {}).find(
|
|
165
|
+
(candidate) =>
|
|
166
|
+
entityTypeNameOf(Definition.modules[candidate]?.definition) ===
|
|
167
|
+
entity.moduleName
|
|
168
|
+
);
|
|
169
|
+
const resolved = resolveScope({
|
|
170
|
+
binding: key === undefined ? undefined : bindingScopeSetting(Definition, key),
|
|
171
|
+
integration: ownership.scope,
|
|
172
|
+
});
|
|
173
|
+
return resolved.decidedBy === 'default' ? null : resolved;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { ResolveIntegrationOwnership };
|
|
@@ -20,18 +20,4 @@ function mapIntegrationClassToIntegrationDTO(integration) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
return [
|
|
25
|
-
...new Set(
|
|
26
|
-
integrationClasses
|
|
27
|
-
.map((integration) =>
|
|
28
|
-
Object.values(integration.Definition.modules).map(
|
|
29
|
-
(module) => module.definition
|
|
30
|
-
)
|
|
31
|
-
)
|
|
32
|
-
.flat()
|
|
33
|
-
),
|
|
34
|
-
];
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
module.exports = { mapIntegrationClassToIntegrationDTO, getModulesDefinitionFromIntegrationClasses };
|
|
23
|
+
module.exports = { mapIntegrationClassToIntegrationDTO };
|
|
@@ -8,6 +8,27 @@ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
|
8
8
|
|
|
9
9
|
const nullable = (schema) => ({ ...schema, nullable: true });
|
|
10
10
|
|
|
11
|
+
// Whose a record is and what the caller may do with it (ADR-056 §3.4). Added to
|
|
12
|
+
// entities, credentials and integrations by the list and get use cases.
|
|
13
|
+
const tenancyProperties = {
|
|
14
|
+
scope: { type: 'string', enum: ['individual', 'organization'], description: 'Whose it is. `global` records are not shown to tenants.' },
|
|
15
|
+
owner: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: { type: { type: 'string', enum: ['individual', 'organization'] }, id: { type: 'string' } },
|
|
18
|
+
description: "The user that owns it: the person, or the organization's user",
|
|
19
|
+
},
|
|
20
|
+
organizationId: nullable({
|
|
21
|
+
type: 'string',
|
|
22
|
+
description:
|
|
23
|
+
'The organization it belongs to. Null for an individual entity connected outside an organization, or under ownership.individualAcross "all".',
|
|
24
|
+
}),
|
|
25
|
+
access: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
enum: ['manage', 'use', 'view'],
|
|
28
|
+
description: 'manage (create, reauthorize, delete), use (bind it, proxy through it) or view (metadata only)',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
11
32
|
const errorBody = {
|
|
12
33
|
type: 'object',
|
|
13
34
|
required: ['error'],
|
|
@@ -69,6 +90,7 @@ const SCHEMAS = {
|
|
|
69
90
|
config: { type: 'object', additionalProperties: true },
|
|
70
91
|
version: nullable({ type: 'string' }),
|
|
71
92
|
messages: { type: 'object', additionalProperties: true },
|
|
93
|
+
...tenancyProperties,
|
|
72
94
|
},
|
|
73
95
|
},
|
|
74
96
|
ListIntegrationsResponse: {
|
|
@@ -129,6 +151,7 @@ const SCHEMAS = {
|
|
|
129
151
|
credentialId: nullable({ type: 'string' }),
|
|
130
152
|
userId: nullable({ type: 'string' }),
|
|
131
153
|
authIsValid: nullable({ type: 'boolean' }),
|
|
154
|
+
...tenancyProperties,
|
|
132
155
|
},
|
|
133
156
|
},
|
|
134
157
|
ListEntitiesResponse: {
|
|
@@ -138,15 +161,31 @@ const SCHEMAS = {
|
|
|
138
161
|
},
|
|
139
162
|
EntityType: {
|
|
140
163
|
type: 'object',
|
|
141
|
-
required: ['type', 'name', 'authType', 'isMultiStep', 'stepCount'],
|
|
164
|
+
required: ['type', 'name', 'authType', 'isMultiStep', 'stepCount', 'usedBy'],
|
|
142
165
|
properties: {
|
|
143
166
|
type: { type: 'string' },
|
|
144
167
|
name: { type: 'string' },
|
|
145
168
|
description: { type: 'string' },
|
|
169
|
+
usedBy: {
|
|
170
|
+
type: 'array',
|
|
171
|
+
items: { type: 'string' },
|
|
172
|
+
description:
|
|
173
|
+
'Names of the integrations that bind this entity type. Empty for a type registered only in appDefinition.entities.',
|
|
174
|
+
},
|
|
146
175
|
authType: { type: 'string', description: 'oauth2, oauth1, api-key, basic or custom' },
|
|
147
176
|
isMultiStep: { type: 'boolean' },
|
|
148
177
|
stepCount: { type: 'integer', minimum: 1 },
|
|
149
178
|
capabilities: {},
|
|
179
|
+
scope: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
description:
|
|
182
|
+
'The scope a new connection of this type gets and the scopes a request may choose (ADR-056 §3.1). `global` is never offered.',
|
|
183
|
+
properties: {
|
|
184
|
+
default: { type: 'string', enum: ['individual', 'organization', 'global'] },
|
|
185
|
+
allow: { type: 'array', items: { type: 'string', enum: ['individual', 'organization'] } },
|
|
186
|
+
source: { type: 'string', enum: ['binding', 'integration', 'entityType', 'app', 'default'] },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
150
189
|
},
|
|
151
190
|
},
|
|
152
191
|
ListEntityTypesResponse: {
|
|
@@ -191,6 +230,12 @@ const SCHEMAS = {
|
|
|
191
230
|
data: { type: 'object', additionalProperties: true, description: 'OAuth callback params or step form data' },
|
|
192
231
|
step: { type: 'integer', minimum: 1, default: 1 },
|
|
193
232
|
sessionId: { type: 'string', description: 'Required for step 2 and later' },
|
|
233
|
+
scope: {
|
|
234
|
+
type: 'string',
|
|
235
|
+
enum: ['individual', 'organization'],
|
|
236
|
+
description:
|
|
237
|
+
"The scope to connect at, from the entity type's allow list (ADR-056 §3.1). Omit to take the type's default. `global` is refused (403).",
|
|
238
|
+
},
|
|
194
239
|
},
|
|
195
240
|
},
|
|
196
241
|
ReauthorizeRequest: {
|
|
@@ -34,6 +34,11 @@ const stepQuery = [
|
|
|
34
34
|
query('step', 'Step number, 1-based (default 1)', { type: 'integer', minimum: 1 }),
|
|
35
35
|
query('sessionId', 'Session from step 1; required for later steps'),
|
|
36
36
|
];
|
|
37
|
+
// ADR-056 §3.4: the same two filters on every list that mixes ownership.
|
|
38
|
+
const listFilters = [
|
|
39
|
+
query('scope', 'Only records of this scope', { type: 'string', enum: ['individual', 'organization'] }),
|
|
40
|
+
query('owner', 'Only the caller\'s own (`me`) or the active organization\'s (`org`)', { type: 'string', enum: ['me', 'org'] }),
|
|
41
|
+
];
|
|
37
42
|
const anyJson = { description: 'Defined by the integration or module' };
|
|
38
43
|
|
|
39
44
|
const V2_OPERATIONS = {
|
|
@@ -41,7 +46,8 @@ const V2_OPERATIONS = {
|
|
|
41
46
|
listIntegrations: {
|
|
42
47
|
summary: "List the caller's integrations",
|
|
43
48
|
description:
|
|
44
|
-
'Returns only `{ integrations }`. Integration types are at /api/v2/integrations/options and connected accounts at /api/v2/entities.',
|
|
49
|
+
'Returns only `{ integrations }`: the caller\'s and the active organization\'s, created in that organization (ADR-056 §3.6). Each carries `scope`, `owner`, `organizationId` and `access`. Integration types are at /api/v2/integrations/options and connected accounts at /api/v2/entities.',
|
|
50
|
+
parameters: listFilters,
|
|
45
51
|
responses: { 200: ok('Integrations', ref('ListIntegrationsResponse')), ...errors('Unauthorized') },
|
|
46
52
|
},
|
|
47
53
|
listIntegrationOptions: {
|
|
@@ -108,14 +114,21 @@ const V2_OPERATIONS = {
|
|
|
108
114
|
// Entities
|
|
109
115
|
listEntities: {
|
|
110
116
|
summary: "List the caller's connected entities",
|
|
117
|
+
description:
|
|
118
|
+
"The active organization's entities and the caller's individual ones (ADR-056 §3.6): an individual entity belongs to the organization it was connected in, unless `ownership.individualAcross` is `all`. Each carries `scope`, `owner`, `organizationId` and `access`.",
|
|
119
|
+
parameters: listFilters,
|
|
111
120
|
responses: { 200: ok('Entities', ref('ListEntitiesResponse')), ...errors('Unauthorized') },
|
|
112
121
|
},
|
|
113
122
|
listEntityTypes: {
|
|
114
123
|
summary: 'List the entity types (API modules) this app can connect',
|
|
124
|
+
description:
|
|
125
|
+
'Every type registered in appDefinition.entities plus every module a declared integration binds. `usedBy` lists the integrations that bind each type; it is empty for a type registered without any integration.',
|
|
115
126
|
responses: { 200: ok('Entity types', ref('ListEntityTypesResponse')), ...errors('Unauthorized') },
|
|
116
127
|
},
|
|
117
128
|
getEntityType: {
|
|
118
129
|
summary: 'Describe an entity type',
|
|
130
|
+
description:
|
|
131
|
+
'Includes `usedBy`, the integrations that bind the type (empty for a registry-only type).',
|
|
119
132
|
responses: { 200: ok('Entity type', ref('EntityType')), ...errors('Unauthorized', 'NotFound') },
|
|
120
133
|
},
|
|
121
134
|
getEntityTypeRequirements: {
|