@friggframework/core 2.0.0--canary.663.8de669c.0 → 2.0.0--canary.686.821f8a3.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/CHANGELOG.md +11 -0
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +2 -0
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +121 -24
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- package/database/organization-where.js +18 -0
- package/database/prisma.js +4 -2
- package/database/unique-violation.js +25 -0
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +187 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +43 -4
- package/generated/prisma-mongodb/index-browser.js +40 -1
- package/generated/prisma-mongodb/index.d.ts +4761 -133
- package/generated/prisma-mongodb/index.js +43 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +92 -0
- package/generated/prisma-mongodb/wasm.js +43 -4
- package/generated/prisma-postgresql/edge.js +43 -4
- package/generated/prisma-postgresql/index-browser.js +40 -1
- package/generated/prisma-postgresql/index.d.ts +4961 -28
- package/generated/prisma-postgresql/index.js +43 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +87 -0
- package/generated/prisma-postgresql/wasm.js +43 -4
- package/handlers/app-definition-loader.js +121 -19
- package/handlers/app-handler-helpers.js +25 -3
- package/handlers/backend-utils.js +31 -25
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +46 -10
- package/handlers/routers/health.js +13 -4
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +65 -1
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +1 -0
- package/integrations/integration-router.js +131 -96
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -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/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +256 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +43 -14
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +151 -0
- package/management-api/meta-router.js +76 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +408 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-errors.js +20 -0
- package/modules/proxy/proxy-path.js +179 -0
- package/modules/proxy/proxy-policy.js +248 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/requester/proxy-request.js +244 -0
- package/modules/requester/requester.js +27 -2
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +286 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +329 -0
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +92 -0
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +87 -0
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +197 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { describeRecord } = require('../../modules/scope-visibility');
|
|
3
|
+
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
4
|
+
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
5
|
+
const { isIntegrationNotFound } = require('../repositories/integration-not-found');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Management API v2 integration operations. They put an access check
|
|
9
|
+
* (missing or unavailable integration = 404) in front of the existing
|
|
10
|
+
* integration use cases, and pass the record's owner id on. Access follows
|
|
11
|
+
* the owner and the organization the integration was created in (ADR-056
|
|
12
|
+
* §3.6), not the owner alone.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const notFound = (integrationId) =>
|
|
16
|
+
Boom.notFound(`Integration ${integrationId} not found`, {
|
|
17
|
+
code: 'INTEGRATION_NOT_FOUND',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The integration record, or 404 when it is missing or the caller may not
|
|
22
|
+
* use it: not theirs or their active organization's, or created in another
|
|
23
|
+
* organization.
|
|
24
|
+
*/
|
|
25
|
+
class GetOwnedIntegrationRecord {
|
|
26
|
+
constructor({ integrationRepository, scopePolicy }) {
|
|
27
|
+
this.integrationRepository = integrationRepository;
|
|
28
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `{ scope, owner, organizationId, access }` of a record to this caller, or null. */
|
|
32
|
+
describe(record, user) {
|
|
33
|
+
return describeRecord(record, this.scopePolicy.accessContext(user), 'integration');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} integrationId
|
|
38
|
+
* @param {Object} user - authenticated User
|
|
39
|
+
* @param {{manage?: boolean}} [options] - `manage: true` for changing or
|
|
40
|
+
* deleting: a person may use an organization's integration but only an
|
|
41
|
+
* admin may change it (403 ORG_ADMIN_REQUIRED)
|
|
42
|
+
*/
|
|
43
|
+
async execute(integrationId, user, { manage = false } = {}) {
|
|
44
|
+
let record = null;
|
|
45
|
+
try {
|
|
46
|
+
record = await this.integrationRepository.findIntegrationById(
|
|
47
|
+
integrationId
|
|
48
|
+
);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (
|
|
51
|
+
/Invalid ID|ObjectId|Malformed/i.test(error?.message || '') ||
|
|
52
|
+
isIntegrationNotFound(error)
|
|
53
|
+
) {
|
|
54
|
+
throw notFound(integrationId);
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
const tenancy = record && this.describe(record, user);
|
|
59
|
+
if (!tenancy) throw notFound(integrationId);
|
|
60
|
+
if (manage) assertCanManage(tenancy.access, `integration ${integrationId}`);
|
|
61
|
+
return record;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates (or reuses) an integration between entities the caller may use.
|
|
67
|
+
* The entities' access, the binding scopes and the integration type are
|
|
68
|
+
* checked before anything is written, and the integration is owned and
|
|
69
|
+
* paired with an organization by `resolveIntegrationOwnership` (§3, §3.6).
|
|
70
|
+
*/
|
|
71
|
+
class CreateIntegrationForUser {
|
|
72
|
+
constructor({ createIntegration, resolveIntegrationOwnership, integrationClasses }) {
|
|
73
|
+
this.createIntegration = createIntegration;
|
|
74
|
+
this.resolveIntegrationOwnership = resolveIntegrationOwnership;
|
|
75
|
+
this.integrationClasses = integrationClasses || [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async execute({ entities, config }, user) {
|
|
79
|
+
if (!config || typeof config !== 'object' || typeof config.type !== 'string') {
|
|
80
|
+
throw Boom.badRequest('config.type is required', {
|
|
81
|
+
code: 'VALIDATION_ERROR',
|
|
82
|
+
details: { field: 'config.type' },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const known = this.integrationClasses.some(
|
|
86
|
+
(IntegrationClass) => IntegrationClass.Definition?.name === config.type
|
|
87
|
+
);
|
|
88
|
+
if (!known) {
|
|
89
|
+
throw Boom.badRequest(`Unknown integration type '${config.type}'`, {
|
|
90
|
+
code: 'UNKNOWN_INTEGRATION_TYPE',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
!Array.isArray(entities) ||
|
|
95
|
+
entities.some((id) => typeof id !== 'string' && typeof id !== 'number')
|
|
96
|
+
) {
|
|
97
|
+
throw Boom.badRequest('entities must be an array of entity ids', {
|
|
98
|
+
code: 'VALIDATION_ERROR',
|
|
99
|
+
details: { field: 'entities' },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const { ownerId, organizationId, adoptUnscoped } =
|
|
103
|
+
await this.resolveIntegrationOwnership.execute({
|
|
104
|
+
entityIds: entities,
|
|
105
|
+
config,
|
|
106
|
+
user,
|
|
107
|
+
});
|
|
108
|
+
return this.createIntegration.execute(
|
|
109
|
+
entities.map(String),
|
|
110
|
+
ownerId,
|
|
111
|
+
config,
|
|
112
|
+
{ organizationId, adoptUnscoped }
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The hydrated integration instance (modules loaded) for an owned integration. */
|
|
118
|
+
class GetIntegrationInstanceForUser {
|
|
119
|
+
constructor({ getOwnedIntegrationRecord, getIntegrationInstance }) {
|
|
120
|
+
this.getOwnedIntegrationRecord = getOwnedIntegrationRecord;
|
|
121
|
+
this.getIntegrationInstance = getIntegrationInstance;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async execute(integrationId, user) {
|
|
125
|
+
const record = await this.getOwnedIntegrationRecord.execute(
|
|
126
|
+
integrationId,
|
|
127
|
+
user
|
|
128
|
+
);
|
|
129
|
+
return this.getIntegrationInstance.execute(record.id, record.userId);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Replaces an owned integration's config. */
|
|
134
|
+
class UpdateIntegrationForUser {
|
|
135
|
+
constructor({ getOwnedIntegrationRecord, updateIntegration }) {
|
|
136
|
+
this.getOwnedIntegrationRecord = getOwnedIntegrationRecord;
|
|
137
|
+
this.updateIntegration = updateIntegration;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async execute(integrationId, user, config) {
|
|
141
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
142
|
+
throw Boom.badRequest('config must be an object', {
|
|
143
|
+
code: 'VALIDATION_ERROR',
|
|
144
|
+
details: { field: 'config' },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const record = await this.getOwnedIntegrationRecord.execute(
|
|
148
|
+
integrationId,
|
|
149
|
+
user,
|
|
150
|
+
{ manage: true }
|
|
151
|
+
);
|
|
152
|
+
return this.updateIntegration.execute(record.id, record.userId, config);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Deletes an owned integration (runs its onDelete hooks). */
|
|
157
|
+
class RemoveIntegrationForUser {
|
|
158
|
+
constructor({ getOwnedIntegrationRecord, deleteIntegrationForUser }) {
|
|
159
|
+
this.getOwnedIntegrationRecord = getOwnedIntegrationRecord;
|
|
160
|
+
this.deleteIntegrationForUser = deleteIntegrationForUser;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async execute(integrationId, user) {
|
|
164
|
+
const record = await this.getOwnedIntegrationRecord.execute(
|
|
165
|
+
integrationId,
|
|
166
|
+
user,
|
|
167
|
+
{ manage: true }
|
|
168
|
+
);
|
|
169
|
+
await this.deleteIntegrationForUser.execute(record.id, record.userId);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sends one of the framework's user-facing events (GET_CONFIG_OPTIONS,
|
|
175
|
+
* GET_USER_ACTIONS, ...) to an owned integration. Handlers pass fixed event
|
|
176
|
+
* names only; caller-chosen action ids go through RunIntegrationActionForUser.
|
|
177
|
+
*/
|
|
178
|
+
class SendIntegrationEventForUser {
|
|
179
|
+
constructor({ getIntegrationInstanceForUser }) {
|
|
180
|
+
this.getIntegrationInstanceForUser = getIntegrationInstanceForUser;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async execute(integrationId, user, event, payload) {
|
|
184
|
+
const instance = await this.getIntegrationInstanceForUser.execute(
|
|
185
|
+
integrationId,
|
|
186
|
+
user
|
|
187
|
+
);
|
|
188
|
+
return instance.send(event, payload);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Runs a user action on an owned integration. Unlike v1, the action id must
|
|
194
|
+
* be one the integration lists as a user action (static USER_ACTION events
|
|
195
|
+
* or its dynamic user actions), so a caller cannot fire internal events such
|
|
196
|
+
* as lifecycle hooks.
|
|
197
|
+
*/
|
|
198
|
+
class RunIntegrationActionForUser {
|
|
199
|
+
constructor({ getIntegrationInstanceForUser }) {
|
|
200
|
+
this.getIntegrationInstanceForUser = getIntegrationInstanceForUser;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async execute(integrationId, user, actionId, payload) {
|
|
204
|
+
const instance = await this.getIntegrationInstanceForUser.execute(
|
|
205
|
+
integrationId,
|
|
206
|
+
user
|
|
207
|
+
);
|
|
208
|
+
const actions =
|
|
209
|
+
typeof instance.loadUserActions === 'function'
|
|
210
|
+
? await instance.loadUserActions()
|
|
211
|
+
: {};
|
|
212
|
+
if (!actions || !Object.prototype.hasOwnProperty.call(actions, actionId)) {
|
|
213
|
+
throw Boom.notFound(
|
|
214
|
+
`Integration ${integrationId} has no user action '${actionId}'`,
|
|
215
|
+
{ code: 'ACTION_NOT_FOUND' }
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return instance.send(actionId, payload);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Runs the integration's auth test and reconciles its status. Returns the
|
|
224
|
+
* errors recorded during the test.
|
|
225
|
+
*/
|
|
226
|
+
class TestIntegrationAuthForUser {
|
|
227
|
+
constructor({ getIntegrationInstanceForUser }) {
|
|
228
|
+
this.getIntegrationInstanceForUser = getIntegrationInstanceForUser;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @returns {Promise<{ ok: boolean, errors: Array<Object> }>} */
|
|
232
|
+
async execute(integrationId, user) {
|
|
233
|
+
const instance = await this.getIntegrationInstanceForUser.execute(
|
|
234
|
+
integrationId,
|
|
235
|
+
user
|
|
236
|
+
);
|
|
237
|
+
const start = Date.now();
|
|
238
|
+
const authPassed = await instance.testAuth();
|
|
239
|
+
await instance.reconcileAuthStatus(authPassed);
|
|
240
|
+
const errors = (instance.record?.messages?.errors || []).filter(
|
|
241
|
+
({ timestamp }) => timestamp >= start
|
|
242
|
+
);
|
|
243
|
+
return { ok: Boolean(authPassed) && errors.length === 0, errors };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
GetOwnedIntegrationRecord,
|
|
249
|
+
CreateIntegrationForUser,
|
|
250
|
+
GetIntegrationInstanceForUser,
|
|
251
|
+
UpdateIntegrationForUser,
|
|
252
|
+
RemoveIntegrationForUser,
|
|
253
|
+
SendIntegrationEventForUser,
|
|
254
|
+
RunIntegrationActionForUser,
|
|
255
|
+
TestIntegrationAuthForUser,
|
|
256
|
+
};
|
|
@@ -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 };
|
|
@@ -19,19 +19,48 @@ function mapIntegrationClassToIntegrationDTO(integration) {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
let deprecationLogged = false;
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
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
|
+
}
|
|
36
62
|
|
|
37
|
-
module.exports = {
|
|
63
|
+
module.exports = {
|
|
64
|
+
mapIntegrationClassToIntegrationDTO,
|
|
65
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
66
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { resolveManagementApiConfig } = require('./management-api-config');
|
|
3
|
+
const {
|
|
4
|
+
createV1DeprecationRouter,
|
|
5
|
+
createV1DisabledRouter,
|
|
6
|
+
} = require('./v1-deprecation');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The Management API served by the auth Lambda (ADR-053): v2 under /api/v2
|
|
10
|
+
* and the frozen, deprecated v1 at the unprefixed paths. With
|
|
11
|
+
* `managementApi.v1: false` the v1 handlers are never built and the v1 paths
|
|
12
|
+
* answer 410.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} [params]
|
|
15
|
+
* @param {Object} [params.appDefinition] - defaults to loadAppDefinition().
|
|
16
|
+
* @param {Function} [params.createV1Router] - defaults to createIntegrationRouter.
|
|
17
|
+
* @param {Function} [params.createV2Router] - defaults to createManagementApiV2Router.
|
|
18
|
+
*/
|
|
19
|
+
function createManagementApiRouter({
|
|
20
|
+
appDefinition,
|
|
21
|
+
createV1Router,
|
|
22
|
+
createV2Router,
|
|
23
|
+
} = {}) {
|
|
24
|
+
const definition =
|
|
25
|
+
appDefinition ||
|
|
26
|
+
require('../handlers/app-definition-loader').loadAppDefinition();
|
|
27
|
+
const config = resolveManagementApiConfig(definition.managementApi);
|
|
28
|
+
|
|
29
|
+
const buildV1 =
|
|
30
|
+
createV1Router ||
|
|
31
|
+
require('../integrations/integration-router').createIntegrationRouter;
|
|
32
|
+
const buildV2 =
|
|
33
|
+
createV2Router ||
|
|
34
|
+
require('./v2/create-v2-router').createManagementApiV2Router;
|
|
35
|
+
|
|
36
|
+
const router = express.Router();
|
|
37
|
+
router.use(buildV2({ appDefinition: definition, config }));
|
|
38
|
+
|
|
39
|
+
if (config.v1) {
|
|
40
|
+
router.use(createV1DeprecationRouter());
|
|
41
|
+
router.use(buildV1());
|
|
42
|
+
} else {
|
|
43
|
+
router.use(createV1DisabledRouter());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return router;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { createManagementApiRouter };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error responses for the HTTP boundary (handlers/app-handler-helpers.js).
|
|
5
|
+
*
|
|
6
|
+
* - v1 and other unversioned routes keep their historical body:
|
|
7
|
+
* `{ error: message }` for 4xx, `{ error: 'Internal Server Error' }` for 5xx.
|
|
8
|
+
* - Management API v2 and /api/meta answer `{ error: { code, message, details? } }`
|
|
9
|
+
* and carry `Frigg-API-Version: 2` even when the error happened before any
|
|
10
|
+
* v2 router ran (for example a body-parser failure).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const API_VERSION_HEADER = 'Frigg-API-Version';
|
|
14
|
+
|
|
15
|
+
const CODE_BY_STATUS = {
|
|
16
|
+
400: 'BAD_REQUEST',
|
|
17
|
+
401: 'UNAUTHORIZED',
|
|
18
|
+
403: 'FORBIDDEN',
|
|
19
|
+
404: 'NOT_FOUND',
|
|
20
|
+
405: 'METHOD_NOT_ALLOWED',
|
|
21
|
+
409: 'CONFLICT',
|
|
22
|
+
410: 'GONE',
|
|
23
|
+
413: 'PAYLOAD_TOO_LARGE',
|
|
24
|
+
415: 'UNSUPPORTED_MEDIA_TYPE',
|
|
25
|
+
422: 'UNPROCESSABLE_ENTITY',
|
|
26
|
+
429: 'RATE_LIMITED',
|
|
27
|
+
500: 'INTERNAL_ERROR',
|
|
28
|
+
501: 'NOT_IMPLEMENTED',
|
|
29
|
+
502: 'BAD_GATEWAY',
|
|
30
|
+
503: 'SERVICE_UNAVAILABLE',
|
|
31
|
+
504: 'GATEWAY_TIMEOUT',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// body-parser (via http-errors) tags its failures with `type`.
|
|
35
|
+
const CODE_BY_BODY_PARSER_TYPE = {
|
|
36
|
+
'entity.parse.failed': 'INVALID_JSON',
|
|
37
|
+
'entity.too.large': 'PAYLOAD_TOO_LARGE',
|
|
38
|
+
'encoding.unsupported': 'UNSUPPORTED_MEDIA_TYPE',
|
|
39
|
+
'charset.unsupported': 'UNSUPPORTED_MEDIA_TYPE',
|
|
40
|
+
'request.aborted': 'BAD_REQUEST',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** True for paths that use the v2 error contract. */
|
|
44
|
+
function isVersionedManagementApiPath(path = '') {
|
|
45
|
+
return (
|
|
46
|
+
path === '/api/v2' ||
|
|
47
|
+
path.startsWith('/api/v2/') ||
|
|
48
|
+
path === '/api/meta' ||
|
|
49
|
+
path.startsWith('/api/meta/')
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The HTTP status for an error. Boom errors carry theirs. Other errors are
|
|
55
|
+
* trusted only when they declare a client error as safe to expose, as
|
|
56
|
+
* http-errors (and so body-parser) does; anything else is a 500, so an
|
|
57
|
+
* upstream FetchError's `statusCode` is never mistaken for ours.
|
|
58
|
+
*/
|
|
59
|
+
function resolveStatusCode(err) {
|
|
60
|
+
if (err?.isBoom) return err.output.statusCode;
|
|
61
|
+
const status = err?.status ?? err?.statusCode;
|
|
62
|
+
if (
|
|
63
|
+
err?.expose === true &&
|
|
64
|
+
Number.isInteger(status) &&
|
|
65
|
+
status >= 400 &&
|
|
66
|
+
status < 500
|
|
67
|
+
) {
|
|
68
|
+
return status;
|
|
69
|
+
}
|
|
70
|
+
return 500;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Normalises any thrown value into a Boom error with the right status. */
|
|
74
|
+
function toBoom(err) {
|
|
75
|
+
if (err?.isBoom) return err;
|
|
76
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
77
|
+
return Boom.boomify(error, { statusCode: resolveStatusCode(err) });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The v2 error body for a Boom error. */
|
|
81
|
+
function toV2ErrorBody(boomError) {
|
|
82
|
+
const statusCode = boomError.output.statusCode;
|
|
83
|
+
const data =
|
|
84
|
+
boomError.data && typeof boomError.data === 'object'
|
|
85
|
+
? boomError.data
|
|
86
|
+
: {};
|
|
87
|
+
const exposeServerError = statusCode >= 500 && data.expose === true;
|
|
88
|
+
|
|
89
|
+
const code =
|
|
90
|
+
(typeof data.code === 'string' && data.code) ||
|
|
91
|
+
CODE_BY_BODY_PARSER_TYPE[boomError.type] ||
|
|
92
|
+
CODE_BY_STATUS[statusCode] ||
|
|
93
|
+
(statusCode >= 500 ? 'INTERNAL_ERROR' : 'BAD_REQUEST');
|
|
94
|
+
|
|
95
|
+
const message =
|
|
96
|
+
statusCode < 500 || exposeServerError
|
|
97
|
+
? boomError.message
|
|
98
|
+
: 'Internal Server Error';
|
|
99
|
+
|
|
100
|
+
const body = { code, message };
|
|
101
|
+
if (
|
|
102
|
+
data.details !== undefined &&
|
|
103
|
+
(statusCode < 500 || exposeServerError)
|
|
104
|
+
) {
|
|
105
|
+
body.details = data.details;
|
|
106
|
+
}
|
|
107
|
+
return { error: body };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
API_VERSION_HEADER,
|
|
112
|
+
isVersionedManagementApiPath,
|
|
113
|
+
resolveStatusCode,
|
|
114
|
+
toBoom,
|
|
115
|
+
toV2ErrorBody,
|
|
116
|
+
};
|