@friggframework/core 2.0.0--canary.679.a2bcc2f.0 → 2.0.0--canary.686.219e1e7.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 +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/use-cases/credential-operations-for-user.js +110 -25
- package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/encryption-schema-registry.js +80 -12
- 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 +167 -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 +27 -4
- package/generated/prisma-mongodb/index-browser.js +24 -1
- package/generated/prisma-mongodb/index.d.ts +3227 -47
- package/generated/prisma-mongodb/index.js +27 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +65 -0
- package/generated/prisma-mongodb/wasm.js +27 -4
- package/generated/prisma-postgresql/edge.js +27 -4
- package/generated/prisma-postgresql/index-browser.js +24 -1
- package/generated/prisma-postgresql/index.d.ts +3519 -67
- package/generated/prisma-postgresql/index.js +27 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +61 -0
- package/generated/prisma-postgresql/wasm.js +27 -4
- package/handlers/app-definition-loader.js +93 -12
- package/handlers/routers/auth.js +40 -12
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/index.js +58 -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/integration-router.js +115 -73
- 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/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +5 -1
- package/integrations/use-cases/integration-operations-for-user.js +55 -24
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/management-api/management-api-config.js +71 -2
- package/management-api/openapi/components.js +40 -0
- package/management-api/openapi/v2-operations.js +16 -4
- package/management-api/v2/dependencies.js +40 -15
- package/management-api/v2/dto.js +20 -0
- package/management-api/v2/handlers/authorization.js +1 -0
- package/management-api/v2/handlers/credentials.js +6 -2
- 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/index.js +8 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +44 -2
- 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/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/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 +50 -13
- package/modules/use-cases/entity-types.js +31 -7
- package/modules/use-cases/execute-entity-proxy-request.js +50 -3
- package/modules/use-cases/get-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +34 -7
- 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 +40 -13
- 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 +65 -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 +61 -0
- package/providers/index.js +13 -0
- package/providers/resolve-provider.js +93 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +148 -0
- package/queues/providers/qstash-queue-provider.js +197 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +187 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +62 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +129 -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
|
@@ -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: {
|
|
@@ -153,6 +176,16 @@ const SCHEMAS = {
|
|
|
153
176
|
isMultiStep: { type: 'boolean' },
|
|
154
177
|
stepCount: { type: 'integer', minimum: 1 },
|
|
155
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
|
+
},
|
|
156
189
|
},
|
|
157
190
|
},
|
|
158
191
|
ListEntityTypesResponse: {
|
|
@@ -173,6 +206,7 @@ const SCHEMAS = {
|
|
|
173
206
|
authIsValid: nullable({ type: 'boolean' }),
|
|
174
207
|
entityIds: { type: 'array', items: { type: 'string' } },
|
|
175
208
|
entityCount: { type: 'integer', minimum: 0 },
|
|
209
|
+
...tenancyProperties,
|
|
176
210
|
createdAt: nullable({ type: 'string', format: 'date-time' }),
|
|
177
211
|
updatedAt: nullable({ type: 'string', format: 'date-time' }),
|
|
178
212
|
data: {
|
|
@@ -197,6 +231,12 @@ const SCHEMAS = {
|
|
|
197
231
|
data: { type: 'object', additionalProperties: true, description: 'OAuth callback params or step form data' },
|
|
198
232
|
step: { type: 'integer', minimum: 1, default: 1 },
|
|
199
233
|
sessionId: { type: 'string', description: 'Required for step 2 and later' },
|
|
234
|
+
scope: {
|
|
235
|
+
type: 'string',
|
|
236
|
+
enum: ['individual', 'organization'],
|
|
237
|
+
description:
|
|
238
|
+
"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).",
|
|
239
|
+
},
|
|
200
240
|
},
|
|
201
241
|
},
|
|
202
242
|
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,6 +114,9 @@ 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: {
|
|
@@ -156,14 +165,14 @@ const V2_OPERATIONS = {
|
|
|
156
165
|
proxyEntityRequest: {
|
|
157
166
|
summary: "Call the entity's API through Frigg (beta)",
|
|
158
167
|
description:
|
|
159
|
-
"ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
|
|
168
|
+
"ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. ADR-056 §3.5: the caller needs use access to the entity in the active organization (an entity they cannot see there is 404), and the entity's scope must be in managementApi.proxy.scopes (default individual only; 403 PROXY_NOT_ALLOWED with details.reason 'scope', or 'access' for view-only access). The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
|
|
160
169
|
requestBody: body(ref('ProxyRequest')),
|
|
161
170
|
responses: {
|
|
162
171
|
200: ok('Upstream answer (the status mirrors the upstream status)', ref('ProxyResponse')),
|
|
163
172
|
400: ok('INVALID_PROXY_REQUEST', ref('ProxyError')),
|
|
164
173
|
401: ok('INVALID_CREDENTIALS or unauthenticated', ref('ProxyError')),
|
|
165
|
-
403: ok(
|
|
166
|
-
404: ok('Entity not found', ref('Error')),
|
|
174
|
+
403: ok("PROXY_NOT_ALLOWED: not in the allow-list, or details.reason 'scope' / 'access'", ref('ProxyError')),
|
|
175
|
+
404: ok('Entity not found, or not visible in the active organization', ref('Error')),
|
|
167
176
|
413: ok('PAYLOAD_TOO_LARGE', ref('ProxyError')),
|
|
168
177
|
415: ok('UNSUPPORTED_MEDIA_TYPE', ref('ProxyError')),
|
|
169
178
|
429: ok('RATE_LIMITED (Retry-After passed through)', ref('ProxyError')),
|
|
@@ -175,6 +184,9 @@ const V2_OPERATIONS = {
|
|
|
175
184
|
// Credentials
|
|
176
185
|
listCredentials: {
|
|
177
186
|
summary: "List the caller's credentials (masked)",
|
|
187
|
+
description:
|
|
188
|
+
'A credential is listed through the entities that use it that the caller may see, so each organization lists only its own connections; one no entity uses is listed to its owner. Each carries `scope`, `owner`, `organizationId` and `access`, taken from who owns the credential.',
|
|
189
|
+
parameters: listFilters,
|
|
178
190
|
responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
|
|
179
191
|
},
|
|
180
192
|
getCredential: {
|
|
@@ -29,13 +29,18 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
29
29
|
getEntityTypeRegistry,
|
|
30
30
|
getEntityTypeDefinitions,
|
|
31
31
|
} = require('../../modules/entity-type-registry');
|
|
32
|
-
const {
|
|
33
|
-
const {
|
|
34
|
-
|
|
32
|
+
const { createScopePolicy } = require('../../modules/scope-policy');
|
|
33
|
+
const {
|
|
34
|
+
AuthorizeEntityForUser,
|
|
35
|
+
} = require('../../modules/use-cases/authorize-entity-for-user');
|
|
36
|
+
const {
|
|
37
|
+
ResolveIntegrationOwnership,
|
|
38
|
+
} = require('../../integrations/use-cases/resolve-integration-ownership');
|
|
35
39
|
const {
|
|
36
40
|
AuthenticateWithSharedSecret,
|
|
37
41
|
} = require('../../user/use-cases/authenticate-with-shared-secret');
|
|
38
42
|
const { AuthenticateUser } = require('../../user/use-cases/authenticate-user');
|
|
43
|
+
const { buildUserAuthentication } = require('../../user/create-user-authentication');
|
|
39
44
|
const { CreateIntegration } = require('../../integrations/use-cases/create-integration');
|
|
40
45
|
const { UpdateIntegration } = require('../../integrations/use-cases/update-integration');
|
|
41
46
|
const {
|
|
@@ -87,6 +92,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
87
92
|
appDefinition.entityTypeRegistry ??
|
|
88
93
|
getEntityTypeRegistry(appDefinition);
|
|
89
94
|
const moduleDefinitions = getEntityTypeDefinitions(registry);
|
|
95
|
+
// Ownership by scope (ADR-056 §3): one object every use case asks.
|
|
96
|
+
const scopePolicy = createScopePolicy({
|
|
97
|
+
ownership: appDefinition.ownership,
|
|
98
|
+
entityTypeRegistry: registry,
|
|
99
|
+
});
|
|
90
100
|
|
|
91
101
|
const moduleRepository = createModuleRepository();
|
|
92
102
|
const integrationRepository = createIntegrationRepository();
|
|
@@ -95,9 +105,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
95
105
|
const authorizationSessionRepository = createAuthorizationSessionRepository();
|
|
96
106
|
|
|
97
107
|
const authenticateUser = new AuthenticateUser({
|
|
98
|
-
|
|
99
|
-
getUserFromXFriggHeaders: new GetUserFromXFriggHeaders({ userRepository, userConfig }),
|
|
100
|
-
getUserFromAdopterJwt: new GetUserFromAdopterJwt({ userRepository, userConfig }),
|
|
108
|
+
...buildUserAuthentication({ userConfig, userRepository }),
|
|
101
109
|
authenticateWithSharedSecret: new AuthenticateWithSharedSecret(),
|
|
102
110
|
userConfig,
|
|
103
111
|
});
|
|
@@ -111,10 +119,12 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
111
119
|
const getEntityModuleForUser = new GetEntityModuleForUser({
|
|
112
120
|
moduleRepository,
|
|
113
121
|
moduleDefinitions,
|
|
122
|
+
scopePolicy,
|
|
114
123
|
});
|
|
115
124
|
const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
|
|
116
125
|
credentialRepository,
|
|
117
126
|
moduleRepository,
|
|
127
|
+
scopePolicy,
|
|
118
128
|
});
|
|
119
129
|
const getAuthorizationRequirements = new GetAuthorizationRequirements({
|
|
120
130
|
moduleDefinitions,
|
|
@@ -128,17 +138,22 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
128
138
|
moduleDefinitions,
|
|
129
139
|
getAuthorizationRequirements,
|
|
130
140
|
authorizationSessionRepository,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
authorizeEntityForUser: new AuthorizeEntityForUser({
|
|
142
|
+
scopePolicy,
|
|
143
|
+
processAuthorizationCallback: new ProcessAuthorizationCallback({
|
|
144
|
+
moduleRepository,
|
|
145
|
+
credentialRepository,
|
|
146
|
+
integrationRepository,
|
|
147
|
+
moduleDefinitions,
|
|
148
|
+
}),
|
|
136
149
|
}),
|
|
137
150
|
moduleRepository,
|
|
138
151
|
credentialRepository,
|
|
152
|
+
scopePolicy,
|
|
139
153
|
});
|
|
140
154
|
const getOwnedIntegrationRecord = new integrationOps.GetOwnedIntegrationRecord({
|
|
141
155
|
integrationRepository,
|
|
156
|
+
scopePolicy,
|
|
142
157
|
});
|
|
143
158
|
const getIntegrationInstanceForUser = new integrationOps.GetIntegrationInstanceForUser({
|
|
144
159
|
getOwnedIntegrationRecord,
|
|
@@ -148,6 +163,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
148
163
|
return {
|
|
149
164
|
config,
|
|
150
165
|
moduleDefinitions,
|
|
166
|
+
scopePolicy,
|
|
151
167
|
repositories: {
|
|
152
168
|
moduleRepository,
|
|
153
169
|
integrationRepository,
|
|
@@ -163,6 +179,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
163
179
|
integrationClasses,
|
|
164
180
|
moduleFactory,
|
|
165
181
|
moduleRepository,
|
|
182
|
+
scopePolicy,
|
|
166
183
|
}),
|
|
167
184
|
getPossibleIntegrations: new GetPossibleIntegrations({ integrationClasses }),
|
|
168
185
|
createIntegrationForUser: new integrationOps.CreateIntegrationForUser({
|
|
@@ -171,7 +188,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
171
188
|
integrationClasses,
|
|
172
189
|
moduleFactory,
|
|
173
190
|
}),
|
|
174
|
-
|
|
191
|
+
resolveIntegrationOwnership: new ResolveIntegrationOwnership({
|
|
192
|
+
moduleRepository,
|
|
193
|
+
integrationClasses,
|
|
194
|
+
scopePolicy,
|
|
195
|
+
}),
|
|
175
196
|
integrationClasses,
|
|
176
197
|
}),
|
|
177
198
|
getIntegrationInstanceForUser,
|
|
@@ -203,7 +224,10 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
203
224
|
|
|
204
225
|
// Entities
|
|
205
226
|
getEntityModuleForUser,
|
|
206
|
-
listEntitiesForUser: new entityOps.ListEntitiesForUser({
|
|
227
|
+
listEntitiesForUser: new entityOps.ListEntitiesForUser({
|
|
228
|
+
moduleRepository,
|
|
229
|
+
scopePolicy,
|
|
230
|
+
}),
|
|
207
231
|
getEntityForUser: new entityOps.GetEntityForUser({ getEntityModuleForUser }),
|
|
208
232
|
deleteEntityForUser: new entityOps.DeleteEntityForUser({
|
|
209
233
|
getEntityModuleForUser,
|
|
@@ -215,14 +239,15 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
|
|
|
215
239
|
refreshEntityOptionsForUser: new entityOps.RefreshEntityOptionsForUser({
|
|
216
240
|
getEntityModuleForUser,
|
|
217
241
|
}),
|
|
218
|
-
listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry }),
|
|
219
|
-
getEntityType: new GetEntityType({ entityTypeRegistry: registry }),
|
|
242
|
+
listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry, scopePolicy }),
|
|
243
|
+
getEntityType: new GetEntityType({ entityTypeRegistry: registry, scopePolicy }),
|
|
220
244
|
getAuthorizationRequirements,
|
|
221
245
|
|
|
222
246
|
// Credentials
|
|
223
247
|
listCredentialsForUser: new credentialOps.ListCredentialsForUser({
|
|
224
248
|
credentialRepository,
|
|
225
249
|
moduleRepository,
|
|
250
|
+
scopePolicy,
|
|
226
251
|
}),
|
|
227
252
|
getCredentialForUser,
|
|
228
253
|
deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
|
package/management-api/v2/dto.js
CHANGED
|
@@ -13,6 +13,20 @@ const idOf = (value) => {
|
|
|
13
13
|
return String(value);
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* `{ scope, owner, organizationId, access }` (ADR-056 §3.4): whose a record is
|
|
18
|
+
* and what the caller may do with it. Empty when the use case did not say.
|
|
19
|
+
*/
|
|
20
|
+
function tenancyFields(tenancy) {
|
|
21
|
+
if (!tenancy) return {};
|
|
22
|
+
return {
|
|
23
|
+
scope: tenancy.scope,
|
|
24
|
+
owner: tenancy.owner,
|
|
25
|
+
organizationId: tenancy.organizationId ?? null,
|
|
26
|
+
access: tenancy.access,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
/** An entity (connected account). */
|
|
17
31
|
function toEntityDto(entity) {
|
|
18
32
|
const credential =
|
|
@@ -30,6 +44,7 @@ function toEntityDto(entity) {
|
|
|
30
44
|
typeof credential?.authIsValid === 'boolean'
|
|
31
45
|
? credential.authIsValid
|
|
32
46
|
: null,
|
|
47
|
+
...tenancyFields(entity.tenancy),
|
|
33
48
|
};
|
|
34
49
|
}
|
|
35
50
|
|
|
@@ -42,6 +57,7 @@ function toIntegrationDto(integration) {
|
|
|
42
57
|
config: integration.config ?? {},
|
|
43
58
|
version: integration.version ?? null,
|
|
44
59
|
messages: integration.messages ?? {},
|
|
60
|
+
...tenancyFields(integration.tenancy),
|
|
45
61
|
};
|
|
46
62
|
}
|
|
47
63
|
|
|
@@ -59,6 +75,8 @@ const CREDENTIAL_METADATA = new Set([
|
|
|
59
75
|
'type',
|
|
60
76
|
'entityIds',
|
|
61
77
|
'status',
|
|
78
|
+
// Added by the credential use cases (ADR-056 §3.4); never provider data.
|
|
79
|
+
'tenancy',
|
|
62
80
|
]);
|
|
63
81
|
|
|
64
82
|
/**
|
|
@@ -110,11 +128,13 @@ function maskCredential(credential) {
|
|
|
110
128
|
entityCount: entityIds.length,
|
|
111
129
|
createdAt: toIso(credential.createdAt),
|
|
112
130
|
updatedAt: toIso(credential.updatedAt),
|
|
131
|
+
...tenancyFields(credential.tenancy),
|
|
113
132
|
data,
|
|
114
133
|
};
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
module.exports = {
|
|
137
|
+
tenancyFields,
|
|
118
138
|
toEntityDto,
|
|
119
139
|
toIntegrationDto,
|
|
120
140
|
maskCredential,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { maskCredential } = require('../dto');
|
|
2
|
+
const { parseListFilters } = require('../request');
|
|
2
3
|
|
|
3
4
|
/** Management API v2 credential handlers, keyed by operationId. */
|
|
4
5
|
function createCredentialHandlers(useCases) {
|
|
@@ -6,8 +7,11 @@ function createCredentialHandlers(useCases) {
|
|
|
6
7
|
useCases;
|
|
7
8
|
|
|
8
9
|
return {
|
|
9
|
-
async listCredentials(
|
|
10
|
-
const credentials = await listCredentialsForUser.execute(
|
|
10
|
+
async listCredentials(req, res, user) {
|
|
11
|
+
const credentials = await listCredentialsForUser.execute(
|
|
12
|
+
user,
|
|
13
|
+
parseListFilters(req.query)
|
|
14
|
+
);
|
|
11
15
|
res.json({ credentials: credentials.map(maskCredential) });
|
|
12
16
|
},
|
|
13
17
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { toEntityDto } = require('../dto');
|
|
2
|
-
const { objectBody, parseStep } = require('../request');
|
|
2
|
+
const { objectBody, parseStep, parseListFilters } = require('../request');
|
|
3
3
|
|
|
4
4
|
/** Management API v2 entity and entity-type handlers, keyed by operationId. */
|
|
5
5
|
function createEntityHandlers(useCases) {
|
|
@@ -16,17 +16,20 @@ function createEntityHandlers(useCases) {
|
|
|
16
16
|
} = useCases;
|
|
17
17
|
|
|
18
18
|
return {
|
|
19
|
-
async listEntities(
|
|
20
|
-
const entities = await listEntitiesForUser.execute(
|
|
19
|
+
async listEntities(req, res, user) {
|
|
20
|
+
const entities = await listEntitiesForUser.execute(
|
|
21
|
+
user,
|
|
22
|
+
parseListFilters(req.query)
|
|
23
|
+
);
|
|
21
24
|
res.json({ entities: entities.map(toEntityDto) });
|
|
22
25
|
},
|
|
23
26
|
|
|
24
|
-
async listEntityTypes(_req, res) {
|
|
25
|
-
res.json({ types: await listEntityTypes.execute() });
|
|
27
|
+
async listEntityTypes(_req, res, user) {
|
|
28
|
+
res.json({ types: await listEntityTypes.execute({ user }) });
|
|
26
29
|
},
|
|
27
30
|
|
|
28
|
-
async getEntityType(req, res) {
|
|
29
|
-
res.json(await getEntityType.execute(req.params.entityType));
|
|
31
|
+
async getEntityType(req, res, user) {
|
|
32
|
+
res.json(await getEntityType.execute(req.params.entityType, { user }));
|
|
30
33
|
},
|
|
31
34
|
|
|
32
35
|
// Describes a step's requirements without starting an authorization
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { toIntegrationDto } = require('../dto');
|
|
2
|
-
const { objectBody, optionalString } = require('../request');
|
|
2
|
+
const { objectBody, optionalString, parseListFilters } = require('../request');
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Management API v2 integration handlers, keyed by registry operationId.
|
|
@@ -27,8 +27,11 @@ function createIntegrationHandlers(useCases) {
|
|
|
27
27
|
);
|
|
28
28
|
|
|
29
29
|
return {
|
|
30
|
-
async listIntegrations(
|
|
31
|
-
const integrations = await getIntegrationsForUser.execute(
|
|
30
|
+
async listIntegrations(req, res, user) {
|
|
31
|
+
const integrations = await getIntegrationsForUser.execute(
|
|
32
|
+
user,
|
|
33
|
+
parseListFilters(req.query)
|
|
34
|
+
);
|
|
32
35
|
res.json({ integrations: integrations.map(toIntegrationDto) });
|
|
33
36
|
},
|
|
34
37
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { LIST_OWNERS } = require('../../modules/scope-visibility');
|
|
2
3
|
|
|
3
4
|
/** The JSON body as an object, or a 400 VALIDATION_ERROR. */
|
|
4
5
|
function objectBody(req) {
|
|
@@ -51,4 +52,34 @@ function optionalString(value, name) {
|
|
|
51
52
|
return value;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
const LIST_SCOPES = ['individual', 'organization'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `?scope=individual|organization` and `?owner=me|org` on a list (ADR-056
|
|
59
|
+
* §3.4). Anything else is a 400.
|
|
60
|
+
*/
|
|
61
|
+
function parseListFilters(query = {}) {
|
|
62
|
+
const scope = optionalString(query.scope, 'scope');
|
|
63
|
+
const owner = optionalString(query.owner, 'owner');
|
|
64
|
+
if (scope !== undefined && !LIST_SCOPES.includes(scope)) {
|
|
65
|
+
throw Boom.badRequest(`scope must be one of ${LIST_SCOPES.join(', ')}`, {
|
|
66
|
+
code: 'VALIDATION_ERROR',
|
|
67
|
+
details: { field: 'scope' },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (owner !== undefined && !Object.hasOwn(LIST_OWNERS, owner)) {
|
|
71
|
+
throw Boom.badRequest('owner must be me or org', {
|
|
72
|
+
code: 'VALIDATION_ERROR',
|
|
73
|
+
details: { field: 'owner' },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return { scope, owner };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
objectBody,
|
|
81
|
+
requireFields,
|
|
82
|
+
parseStep,
|
|
83
|
+
optionalString,
|
|
84
|
+
parseListFilters,
|
|
85
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity scopes and ownership (ADR-056 §3), re-exported from the four files it
|
|
3
|
+
* is split into:
|
|
4
|
+
*
|
|
5
|
+
* scope-resolution constants, scope resolution, ownership on create
|
|
6
|
+
* scope-config reading and validating app-definition settings
|
|
7
|
+
* scope-visibility who may see which stored row
|
|
8
|
+
* scope-policy the object a use case asks
|
|
9
|
+
*/
|
|
10
|
+
module.exports = {
|
|
11
|
+
...require('./scope-resolution'),
|
|
12
|
+
...require('./scope-config'),
|
|
13
|
+
...require('./scope-visibility'),
|
|
14
|
+
...require('./scope-policy'),
|
|
15
|
+
};
|
package/modules/index.js
CHANGED
|
@@ -8,6 +8,11 @@ const {
|
|
|
8
8
|
getEntityTypeRegistry,
|
|
9
9
|
EntityRegistryError,
|
|
10
10
|
} = require('./entity-type-registry');
|
|
11
|
+
const {
|
|
12
|
+
resolveScope,
|
|
13
|
+
listBindingScopes,
|
|
14
|
+
OwnershipConfigError,
|
|
15
|
+
} = require('./entity-scope');
|
|
11
16
|
|
|
12
17
|
module.exports = {
|
|
13
18
|
ApiKeyRequester,
|
|
@@ -18,4 +23,7 @@ module.exports = {
|
|
|
18
23
|
ModuleFactory,
|
|
19
24
|
getEntityTypeRegistry,
|
|
20
25
|
EntityRegistryError,
|
|
26
|
+
resolveScope,
|
|
27
|
+
listBindingScopes,
|
|
28
|
+
OwnershipConfigError,
|
|
21
29
|
};
|
|
@@ -45,6 +45,18 @@ class ModuleFactory {
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// definition.env may be an async function (e.g. the db-credentials
|
|
49
|
+
// extension's bootstrap loads OAuth app credentials from the
|
|
50
|
+
// database); resolve it first. A static env keeps the plain
|
|
51
|
+
// constructor, exactly as before.
|
|
52
|
+
if (typeof moduleDefinition.env === 'function') {
|
|
53
|
+
return await Module.create({
|
|
54
|
+
userId,
|
|
55
|
+
entity,
|
|
56
|
+
definition: moduleDefinition,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
return new Module({
|
|
49
61
|
userId,
|
|
50
62
|
entity,
|
package/modules/module.js
CHANGED
|
@@ -21,8 +21,9 @@ class Module extends Delegate {
|
|
|
21
21
|
* @param {string} params.userId The user id
|
|
22
22
|
* @param {Object} params.entity The entity record from the database
|
|
23
23
|
* @param {string} [params.state] Optional OAuth state value forwarded to the API client (round-trips through the OAuth provider).
|
|
24
|
+
* @param {Object} [params.resolvedEnv] definition.env already resolved by Module.create(); defaults to the static definition.env.
|
|
24
25
|
*/
|
|
25
|
-
constructor({ definition, userId = null, entity: entityObj = null, state = null }) {
|
|
26
|
+
constructor({ definition, userId = null, entity: entityObj = null, state = null, resolvedEnv = null }) {
|
|
26
27
|
super({ definition, userId, entity: entityObj });
|
|
27
28
|
|
|
28
29
|
this.validateDefinition(definition);
|
|
@@ -54,8 +55,22 @@ class Module extends Delegate {
|
|
|
54
55
|
|
|
55
56
|
Object.assign(this, this.definition.requiredAuthMethods);
|
|
56
57
|
|
|
58
|
+
// A pre-resolved env (Module.create()) supports definition.env as an
|
|
59
|
+
// async function, e.g. OAuth app credentials loaded from the database
|
|
60
|
+
// by the db-credentials extension; otherwise the static object.
|
|
61
|
+
// No resolved env (the null default; an explicit undefined takes it
|
|
62
|
+
// too) means the static definition.env applies.
|
|
63
|
+
const envValues = resolvedEnv ?? this.definition.env;
|
|
64
|
+
if (typeof envValues === 'function') {
|
|
65
|
+
// Spreading a function yields no keys, so the API client would
|
|
66
|
+
// silently get no client_id / client_secret.
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
`Module ${this.name}: definition.env is a function; build the module with await Module.create(...)`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
const apiParams = {
|
|
58
|
-
...
|
|
73
|
+
...envValues,
|
|
59
74
|
delegate: this,
|
|
60
75
|
logger: this.logger,
|
|
61
76
|
...(state ? { state } : {}),
|
|
@@ -67,6 +82,33 @@ class Module extends Delegate {
|
|
|
67
82
|
this.api = new this.apiClass(apiParams);
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Resolves definition.env, supporting both a static object and a
|
|
87
|
+
* (possibly async) function.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} definition - Module definition
|
|
90
|
+
* @returns {Promise<Object|undefined>} Resolved env values
|
|
91
|
+
*/
|
|
92
|
+
static async resolveEnv(definition) {
|
|
93
|
+
if (typeof definition.env === 'function') {
|
|
94
|
+
return await definition.env();
|
|
95
|
+
}
|
|
96
|
+
return definition.env;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Async factory that resolves definition.env first, so a module whose
|
|
101
|
+
* env is a function gets its values. Prefer it over `new Module(...)`
|
|
102
|
+
* wherever the call site can await.
|
|
103
|
+
*
|
|
104
|
+
* @param {Object} params - same as the constructor, minus resolvedEnv
|
|
105
|
+
* @returns {Promise<Module>}
|
|
106
|
+
*/
|
|
107
|
+
static async create({ definition, userId = null, entity = null, state = null }) {
|
|
108
|
+
const resolvedEnv = await Module.resolveEnv(definition);
|
|
109
|
+
return new Module({ definition, userId, entity, state, resolvedEnv });
|
|
110
|
+
}
|
|
111
|
+
|
|
70
112
|
getName() {
|
|
71
113
|
return this.name;
|
|
72
114
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error `findEntityById` throws for an id with no row, and the one
|
|
3
|
+
* predicate that recognises it (as integration-not-found.js does for
|
|
4
|
+
* integrations). The adapters build the error here and use cases match it
|
|
5
|
+
* here, so a missing entity can be told from any other failure without
|
|
6
|
+
* widening a regex.
|
|
7
|
+
*
|
|
8
|
+
* Why it matters: a use case that answers 404 for another tenant's entity must
|
|
9
|
+
* answer the same 404 for an id that does not exist. Otherwise the status code
|
|
10
|
+
* says which ids exist (ADR-052 §3, ADR-056 §3.6).
|
|
11
|
+
*/
|
|
12
|
+
const MESSAGE = /^Entity .+ not found$/;
|
|
13
|
+
|
|
14
|
+
const entityNotFoundError = (id) => new Error(`Entity ${id} not found`);
|
|
15
|
+
|
|
16
|
+
const isEntityNotFound = (error) => MESSAGE.test(error?.message || '');
|
|
17
|
+
|
|
18
|
+
module.exports = { entityNotFoundError, isEntityNotFound };
|