@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,139 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { ModuleConstants } = require('../ModuleConstants');
|
|
3
|
+
const { usedByOf } = require('../entity-type-registry');
|
|
4
|
+
|
|
5
|
+
const AUTH_TYPE_BY_REQUESTER = {
|
|
6
|
+
[ModuleConstants.authType.oauth2]: 'oauth2',
|
|
7
|
+
[ModuleConstants.authType.oauth1]: 'oauth1',
|
|
8
|
+
[ModuleConstants.authType.apiKey]: 'api-key',
|
|
9
|
+
[ModuleConstants.authType.basic]: 'basic',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const callIfFunction = (target, name) =>
|
|
13
|
+
typeof target?.[name] === 'function' ? target[name]() : undefined;
|
|
14
|
+
|
|
15
|
+
/** Number of authorization steps a module declares (1 unless multi-step). */
|
|
16
|
+
function getAuthStepCount(definition) {
|
|
17
|
+
const count = callIfFunction(definition, 'getAuthStepCount');
|
|
18
|
+
return Number.isInteger(count) && count > 0 ? count : 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Public description of an API module (an "entity type"). Reads only the
|
|
23
|
+
* definition: display metadata, auth type and step count. Never credentials
|
|
24
|
+
* or env values.
|
|
25
|
+
*/
|
|
26
|
+
function describeEntityType(definition, typeName = definition.moduleName) {
|
|
27
|
+
const stepCount = getAuthStepCount(definition);
|
|
28
|
+
const display = definition.display || {};
|
|
29
|
+
const description = {
|
|
30
|
+
type: typeName,
|
|
31
|
+
name:
|
|
32
|
+
callIfFunction(definition, 'getDisplayName') ??
|
|
33
|
+
display.label ??
|
|
34
|
+
display.name ??
|
|
35
|
+
typeName,
|
|
36
|
+
authType:
|
|
37
|
+
callIfFunction(definition, 'getAuthType') ??
|
|
38
|
+
AUTH_TYPE_BY_REQUESTER[definition.API?.requesterType] ??
|
|
39
|
+
'custom',
|
|
40
|
+
isMultiStep: stepCount > 1,
|
|
41
|
+
stepCount,
|
|
42
|
+
};
|
|
43
|
+
const text = callIfFunction(definition, 'getDescription') ?? display.description;
|
|
44
|
+
if (text) description.description = text;
|
|
45
|
+
const capabilities = callIfFunction(definition, 'getCapabilities');
|
|
46
|
+
if (capabilities !== undefined) description.capabilities = capabilities;
|
|
47
|
+
return description;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findDefinition(moduleDefinitions, entityType) {
|
|
51
|
+
const definition = moduleDefinitions.find(
|
|
52
|
+
(def) => def.moduleName === entityType
|
|
53
|
+
);
|
|
54
|
+
if (!definition) {
|
|
55
|
+
throw Boom.notFound(`Entity type '${entityType}' not found`, {
|
|
56
|
+
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return definition;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* With a scope policy the entry also says which scope a new connection gets
|
|
64
|
+
* and which a request may choose (ADR-056 §3.1), so a UI can offer the choice.
|
|
65
|
+
* `global` is never offered: a tenant request cannot create one.
|
|
66
|
+
*/
|
|
67
|
+
const scopeOf = (scopePolicy, name, user) => {
|
|
68
|
+
if (!scopePolicy) return {};
|
|
69
|
+
const { scope, source, allow } = scopePolicy.resolveScope({
|
|
70
|
+
entityType: name,
|
|
71
|
+
user,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
scope: {
|
|
75
|
+
default: scope,
|
|
76
|
+
allow: allow.filter((value) => value !== 'global'),
|
|
77
|
+
source,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const describeEntry = (name, entry, scopePolicy, user) => ({
|
|
83
|
+
// The registry key is the name the API uses, whatever the definition spells.
|
|
84
|
+
...describeEntityType(entry.definition, name),
|
|
85
|
+
usedBy: usedByOf(entry),
|
|
86
|
+
...scopeOf(scopePolicy, name, user),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function requireRegistry(entityTypeRegistry) {
|
|
90
|
+
if (!(entityTypeRegistry instanceof Map)) {
|
|
91
|
+
throw new TypeError('entityTypeRegistry is required (see getEntityTypeRegistry)');
|
|
92
|
+
}
|
|
93
|
+
return entityTypeRegistry;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Lists the entity types this app can authorize: registry entries plus every
|
|
98
|
+
* module an integration binds (ADR-059). `usedBy` names the integrations that
|
|
99
|
+
* bind each type; it is empty for a registry-only type.
|
|
100
|
+
*/
|
|
101
|
+
class ListEntityTypes {
|
|
102
|
+
constructor({ entityTypeRegistry, scopePolicy }) {
|
|
103
|
+
this.registry = requireRegistry(entityTypeRegistry);
|
|
104
|
+
this.scopePolicy = scopePolicy;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {{user?: Object}} [params] - the caller, for the level-6 scope default */
|
|
108
|
+
async execute({ user } = {}) {
|
|
109
|
+
return [...this.registry]
|
|
110
|
+
.map(([name, entry]) => describeEntry(name, entry, this.scopePolicy, user))
|
|
111
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Describes one entity type, looked up by its registry key; 404 when absent. */
|
|
116
|
+
class GetEntityType {
|
|
117
|
+
constructor({ entityTypeRegistry, scopePolicy }) {
|
|
118
|
+
this.registry = requireRegistry(entityTypeRegistry);
|
|
119
|
+
this.scopePolicy = scopePolicy;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async execute(entityType, { user } = {}) {
|
|
123
|
+
const entry = this.registry.get(entityType);
|
|
124
|
+
if (!entry) {
|
|
125
|
+
throw Boom.notFound(`Entity type '${entityType}' not found`, {
|
|
126
|
+
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return describeEntry(entityType, entry, this.scopePolicy, user);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
ListEntityTypes,
|
|
135
|
+
GetEntityType,
|
|
136
|
+
describeEntityType,
|
|
137
|
+
getAuthStepCount,
|
|
138
|
+
findDefinition,
|
|
139
|
+
};
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { getLogger } = require('../../logs');
|
|
3
|
+
const { getTelemetry } = require('../../telemetry/telemetry-runtime');
|
|
4
|
+
const { ProxyRequestError } = require('../proxy/proxy-errors');
|
|
5
|
+
const { SCOPES } = require('../scope-resolution');
|
|
6
|
+
const { grantsUse } = require('../scope-visibility');
|
|
7
|
+
const { normalizeProxyPath, matchAllowRule } = require('../proxy/proxy-path');
|
|
8
|
+
const {
|
|
9
|
+
validateProxyRequest,
|
|
10
|
+
resolveAllowRules,
|
|
11
|
+
filterRequestHeaders,
|
|
12
|
+
filterResponseHeaders,
|
|
13
|
+
decodeResponseBody,
|
|
14
|
+
} = require('../proxy/proxy-policy');
|
|
15
|
+
|
|
16
|
+
const log = getLogger('frigg.api.proxy');
|
|
17
|
+
|
|
18
|
+
/** HTTP status and audit outcome for each proxy error code (ADR-052 §10). */
|
|
19
|
+
const ERROR_MAP = {
|
|
20
|
+
INVALID_PROXY_REQUEST: { status: 400, outcome: 'denied' },
|
|
21
|
+
PROXY_NOT_ALLOWED: { status: 403, outcome: 'denied' },
|
|
22
|
+
PAYLOAD_TOO_LARGE: { status: 413, outcome: 'too_large' },
|
|
23
|
+
UNSUPPORTED_MEDIA_TYPE: { status: 415, outcome: 'denied' },
|
|
24
|
+
PROXY_NOT_CONFIGURED: { status: 501, outcome: 'denied' },
|
|
25
|
+
INVALID_CREDENTIALS: { status: 401, outcome: 'upstream_error' },
|
|
26
|
+
RATE_LIMITED: { status: 429, outcome: 'upstream_error' },
|
|
27
|
+
UPSTREAM_ERROR: { status: 502, outcome: 'upstream_error' },
|
|
28
|
+
NETWORK_ERROR: { status: 502, outcome: 'upstream_error' },
|
|
29
|
+
RESPONSE_TOO_LARGE: { status: 502, outcome: 'too_large' },
|
|
30
|
+
TIMEOUT: { status: 504, outcome: 'timeout' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function toBoom(error, extra = {}) {
|
|
34
|
+
const mapping = ERROR_MAP[error.code] || { status: 502 };
|
|
35
|
+
const boom = new Boom.Boom(error.message, {
|
|
36
|
+
statusCode: mapping.status,
|
|
37
|
+
data: {
|
|
38
|
+
code: error.code,
|
|
39
|
+
details: error.details,
|
|
40
|
+
// Every proxy message is written for the caller (no URLs, no
|
|
41
|
+
// credential material), including the 5xx ones.
|
|
42
|
+
expose: true,
|
|
43
|
+
proxy: true,
|
|
44
|
+
...extra,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return boom;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const authHeaderNamesOf = (api) =>
|
|
51
|
+
[api?.api_key_name, api?.API_KEY_NAME].filter(
|
|
52
|
+
(name) => typeof name === 'string' && name
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* POST /api/v2/entities/:entityId/proxy (ADR-052): calls the entity's API
|
|
57
|
+
* through its module with the stored credential, inside the limits the app
|
|
58
|
+
* and the module set.
|
|
59
|
+
*
|
|
60
|
+
* Order of checks: request shape and size, path rules, entity visibility in
|
|
61
|
+
* the active organization (404), `use` access (403), the entity's scope
|
|
62
|
+
* against `managementApi.proxy.scopes` (403), the module's allow-list (403,
|
|
63
|
+
* deny by default), credential readiness (401), then the call through
|
|
64
|
+
* Requester._proxyRequest. Every call,
|
|
65
|
+
* allowed or denied, writes one audit record and one usage count. Bodies,
|
|
66
|
+
* headers, query values and the raw path are never logged.
|
|
67
|
+
*/
|
|
68
|
+
class ExecuteEntityProxyRequest {
|
|
69
|
+
/**
|
|
70
|
+
* @param {Object} params
|
|
71
|
+
* @param {import('./get-entity-module-for-user').GetEntityModuleForUser} params.getEntityModuleForUser
|
|
72
|
+
* @param {ReturnType<import('../../management-api/management-api-config').resolveManagementApiConfig>['proxy']} params.proxyConfig
|
|
73
|
+
* @param {Object} [params.telemetry]
|
|
74
|
+
* @param {() => number} [params.now]
|
|
75
|
+
*/
|
|
76
|
+
constructor({ getEntityModuleForUser, proxyConfig, telemetry, now }) {
|
|
77
|
+
this.getEntityModuleForUser = getEntityModuleForUser;
|
|
78
|
+
this.proxyConfig = proxyConfig;
|
|
79
|
+
this.telemetry = telemetry;
|
|
80
|
+
this.now = now || Date.now;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} entityId
|
|
85
|
+
* @param {Object} user - authenticated User
|
|
86
|
+
* @param {Object} request - { method, path, query?, headers?, body? }
|
|
87
|
+
* @returns {Promise<{ status: number, body: { success: boolean, status: number, headers: Object, data: unknown, dataEncoding?: string } }>}
|
|
88
|
+
*/
|
|
89
|
+
async execute(entityId, user, request) {
|
|
90
|
+
const started = this.now();
|
|
91
|
+
const audit = {
|
|
92
|
+
userId: user?.getId?.(),
|
|
93
|
+
organizationId: user?.getOrganizationUser?.()?.id,
|
|
94
|
+
entityId: String(entityId),
|
|
95
|
+
method: typeof request?.method === 'string' ? request.method.toUpperCase() : undefined,
|
|
96
|
+
requestBytes: 0,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const validated = validateProxyRequest(request, this.proxyConfig);
|
|
101
|
+
audit.method = validated.method;
|
|
102
|
+
audit.requestBytes = validated.requestBytes;
|
|
103
|
+
const path = normalizeProxyPath(validated.path);
|
|
104
|
+
|
|
105
|
+
// Visible in the active organization, or 404 (ADR-056 §3.6): the
|
|
106
|
+
// same clauses the v2 lists use, so a missing entity, another
|
|
107
|
+
// person's and one paired with another organization look alike.
|
|
108
|
+
const { entity, definition, module } =
|
|
109
|
+
await this.getEntityModuleForUser.execute(entityId, user);
|
|
110
|
+
audit.module = definition.moduleName;
|
|
111
|
+
this.assertProxyable(entityId, entity, definition, user);
|
|
112
|
+
|
|
113
|
+
const credentialRef = entity.credential;
|
|
114
|
+
const credentialId =
|
|
115
|
+
credentialRef && typeof credentialRef === 'object'
|
|
116
|
+
? credentialRef.id ?? credentialRef._id
|
|
117
|
+
: credentialRef;
|
|
118
|
+
if (credentialId !== undefined && credentialId !== null) {
|
|
119
|
+
audit.credentialId = String(credentialId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { rules } = resolveAllowRules(definition, this.proxyConfig.modules);
|
|
123
|
+
const rule = matchAllowRule(rules, validated.method, path);
|
|
124
|
+
if (!rule) {
|
|
125
|
+
throw new ProxyRequestError(
|
|
126
|
+
'PROXY_NOT_ALLOWED',
|
|
127
|
+
`Module ${definition.moduleName} does not allow ${validated.method} on this path`,
|
|
128
|
+
{ module: definition.moduleName, method: validated.method }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
audit.pattern = rule.path;
|
|
132
|
+
|
|
133
|
+
const credential = entity.credential;
|
|
134
|
+
if (!credential || (typeof credential === 'object' && credential.authIsValid === false)) {
|
|
135
|
+
throw new ProxyRequestError(
|
|
136
|
+
'INVALID_CREDENTIALS',
|
|
137
|
+
'The entity has no usable credential; re-authorize it'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const authHeaderNames = authHeaderNamesOf(module.api);
|
|
142
|
+
const headers = filterRequestHeaders(validated.headers, {
|
|
143
|
+
moduleHeaders: Array.isArray(definition.proxy?.headers) ? definition.proxy.headers : [],
|
|
144
|
+
authHeaderNames,
|
|
145
|
+
});
|
|
146
|
+
if (validated.body !== undefined && !headers['content-type']) {
|
|
147
|
+
headers['content-type'] = validated.bodyIsJson ? 'application/json' : 'text/plain';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const upstream = await module.api._proxyRequest({
|
|
151
|
+
method: validated.method,
|
|
152
|
+
path,
|
|
153
|
+
query: validated.query,
|
|
154
|
+
headers,
|
|
155
|
+
body: validated.body,
|
|
156
|
+
baseUrl: this.resolveBaseUrl(definition, module.api),
|
|
157
|
+
timeoutMs: this.proxyConfig.timeoutMs,
|
|
158
|
+
maxResponseBytes: this.proxyConfig.maxResponseBytes,
|
|
159
|
+
isAllowedRedirect: (nextPath, nextMethod) =>
|
|
160
|
+
Boolean(matchAllowRule(rules, nextMethod, nextPath)),
|
|
161
|
+
});
|
|
162
|
+
audit.upstreamStatus = upstream.status;
|
|
163
|
+
audit.responseBytes = upstream.body?.length ?? 0;
|
|
164
|
+
|
|
165
|
+
if (upstream.status === 429) {
|
|
166
|
+
throw Object.assign(
|
|
167
|
+
new ProxyRequestError('RATE_LIMITED', 'The upstream API is rate limiting this account', {
|
|
168
|
+
upstreamStatus: 429,
|
|
169
|
+
}),
|
|
170
|
+
{ retryAfter: upstream.headers['retry-after'] }
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (upstream.status >= 500) {
|
|
174
|
+
throw new ProxyRequestError('UPSTREAM_ERROR', 'The upstream API returned an error', {
|
|
175
|
+
upstreamStatus: upstream.status,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const decoded = decodeResponseBody(upstream.body, upstream.contentType, {
|
|
180
|
+
binary: definition.proxy?.binary === true,
|
|
181
|
+
});
|
|
182
|
+
const result = {
|
|
183
|
+
status: upstream.status,
|
|
184
|
+
body: {
|
|
185
|
+
success: upstream.status < 400,
|
|
186
|
+
status: upstream.status,
|
|
187
|
+
headers: filterResponseHeaders(upstream.headers, {
|
|
188
|
+
redirectBlocked: upstream.redirectBlocked,
|
|
189
|
+
authHeaderNames,
|
|
190
|
+
}),
|
|
191
|
+
data: decoded.data,
|
|
192
|
+
...(decoded.encoding && { dataEncoding: decoded.encoding }),
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
this.record(audit, upstream.status < 400 ? 'ok' : 'upstream_error', started);
|
|
196
|
+
return result;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof ProxyRequestError) {
|
|
199
|
+
const { outcome = 'upstream_error' } = ERROR_MAP[error.code] || {};
|
|
200
|
+
this.record(audit, outcome, started, error.code);
|
|
201
|
+
throw toBoom(error, error.retryAfter ? { retryAfter: error.retryAfter } : {});
|
|
202
|
+
}
|
|
203
|
+
const notFound = error?.isBoom && error.output.statusCode === 404;
|
|
204
|
+
this.record(
|
|
205
|
+
audit,
|
|
206
|
+
notFound ? 'denied' : 'error',
|
|
207
|
+
started,
|
|
208
|
+
error?.data?.code || (error?.isBoom ? undefined : 'INTERNAL_ERROR')
|
|
209
|
+
);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* ADR-056 §3.5: the caller needs `use` access to the entity in the active
|
|
216
|
+
* organization, and the entity's scope must be one the app lets the proxy
|
|
217
|
+
* accept. `access` and `scope` come from the description the v2 lists
|
|
218
|
+
* return (GetEntityModuleForUser.describe), never from a second rule.
|
|
219
|
+
*
|
|
220
|
+
* Both refusals are 403 PROXY_NOT_ALLOWED: the caller can already see the
|
|
221
|
+
* entity, so saying it exists leaks nothing. `details.reason` tells them
|
|
222
|
+
* apart ('access' or 'scope').
|
|
223
|
+
*/
|
|
224
|
+
assertProxyable(entityId, entity, definition, user) {
|
|
225
|
+
const tenancy = this.getEntityModuleForUser.describe(entity, user);
|
|
226
|
+
if (!tenancy) {
|
|
227
|
+
// Not visible after all: the same 404 as a missing entity.
|
|
228
|
+
throw Boom.notFound(`Entity ${entityId} not found`, {
|
|
229
|
+
code: 'ENTITY_NOT_FOUND',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (!grantsUse(tenancy.access)) {
|
|
233
|
+
throw new ProxyRequestError(
|
|
234
|
+
'PROXY_NOT_ALLOWED',
|
|
235
|
+
'Proxying through an entity needs use access to it; you can only view this one',
|
|
236
|
+
{ module: definition.moduleName, reason: 'access' }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
// Narrowest by default (§3.5): with no setting only individual
|
|
240
|
+
// entities are proxied, because proxying through a shared credential
|
|
241
|
+
// lets any member act on the organization's account.
|
|
242
|
+
const acceptedScopes = this.proxyConfig.scopes ?? [SCOPES.INDIVIDUAL];
|
|
243
|
+
if (!acceptedScopes.includes(tenancy.scope)) {
|
|
244
|
+
throw new ProxyRequestError(
|
|
245
|
+
'PROXY_NOT_ALLOWED',
|
|
246
|
+
`The entity proxy does not accept ${tenancy.scope} entities in this app`,
|
|
247
|
+
{ module: definition.moduleName, reason: 'scope', scope: tenancy.scope }
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A module may pin its proxy base URL (string, or a function of the api). */
|
|
253
|
+
resolveBaseUrl(definition, api) {
|
|
254
|
+
const declared = definition.proxy?.baseUrl;
|
|
255
|
+
if (typeof declared === 'function') return declared(api);
|
|
256
|
+
if (typeof declared === 'string') return declared;
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** One audit record (ADR-048) and one usage count (ADR-011) per call. */
|
|
261
|
+
record(audit, outcome, started, reason) {
|
|
262
|
+
const fields = {
|
|
263
|
+
eventName: 'frigg.api.proxy.request',
|
|
264
|
+
...audit,
|
|
265
|
+
outcome,
|
|
266
|
+
durationMs: this.now() - started,
|
|
267
|
+
...(reason && { reason }),
|
|
268
|
+
};
|
|
269
|
+
if (outcome === 'ok' || outcome === 'upstream_error') {
|
|
270
|
+
log.info('Entity proxy request', fields);
|
|
271
|
+
} else {
|
|
272
|
+
log.warn('Entity proxy request refused', fields);
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
(this.telemetry || getTelemetry()).count('frigg.api.proxy.requests', 1, {
|
|
276
|
+
module: audit.module || 'unknown',
|
|
277
|
+
method: audit.method || 'unknown',
|
|
278
|
+
outcome,
|
|
279
|
+
});
|
|
280
|
+
} catch {
|
|
281
|
+
// Telemetry must never break a request.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { ExecuteEntityProxyRequest, ERROR_MAP };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { findDefinition, getAuthStepCount } = require('./entity-types');
|
|
3
|
+
|
|
4
|
+
const defaultCreateModule = (params) => {
|
|
5
|
+
const { Module } = require('../module');
|
|
6
|
+
return Module.create(params);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalises what a module returns for a step into the v2 requirements
|
|
11
|
+
* shape: `{ type, data }`. Modules either return `{ type, data }` already
|
|
12
|
+
* (multi-step, JSON-schema forms) or a flat object such as the OAuth2
|
|
13
|
+
* requester's `{ type: 'oauth2', url }`.
|
|
14
|
+
*/
|
|
15
|
+
function normalizeRequirements(raw) {
|
|
16
|
+
if (!raw || typeof raw !== 'object') return { type: 'custom', data: {} };
|
|
17
|
+
if (raw.data && typeof raw.data === 'object') {
|
|
18
|
+
return { ...raw, type: raw.type || 'custom' };
|
|
19
|
+
}
|
|
20
|
+
const { type, ...data } = raw;
|
|
21
|
+
return { type: type || 'custom', data };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Single-step requirements, from wherever the module declares them: a
|
|
26
|
+
* requiredAuthMethods.getAuthorizationRequirements(api) (the form-based
|
|
27
|
+
* API-key convention `frigg auth` uses), or the Api's own
|
|
28
|
+
* getAuthorizationRequirements() (OAuth2Requester builds the authorize URL).
|
|
29
|
+
*/
|
|
30
|
+
async function singleStepRequirements(definition, module) {
|
|
31
|
+
const declared = definition.requiredAuthMethods?.getAuthorizationRequirements;
|
|
32
|
+
if (typeof declared === 'function') return declared(module.api);
|
|
33
|
+
if (typeof module.api?.getAuthorizationRequirements === 'function') {
|
|
34
|
+
return module.api.getAuthorizationRequirements();
|
|
35
|
+
}
|
|
36
|
+
throw Boom.notImplemented(
|
|
37
|
+
`Module ${definition.moduleName} declares no authorization requirements (add requiredAuthMethods.getAuthorizationRequirements)`,
|
|
38
|
+
{ code: 'REQUIREMENTS_NOT_DECLARED', expose: true }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the caller must submit for one authorization step of an entity type
|
|
44
|
+
* (Management API v2). Single-step modules use their Api's
|
|
45
|
+
* getAuthorizationRequirements(); multi-step modules declare
|
|
46
|
+
* getAuthStepCount() and getAuthRequirementsForStep(step) on their
|
|
47
|
+
* definition (docs/MULTI_STEP_AUTH_MIGRATION_GUIDE.md).
|
|
48
|
+
*/
|
|
49
|
+
class GetAuthorizationRequirements {
|
|
50
|
+
/**
|
|
51
|
+
* @param {Object} params
|
|
52
|
+
* @param {Array<Object>} params.moduleDefinitions
|
|
53
|
+
* @param {Function} [params.createModule]
|
|
54
|
+
*/
|
|
55
|
+
constructor({ moduleDefinitions, createModule }) {
|
|
56
|
+
this.moduleDefinitions = moduleDefinitions || [];
|
|
57
|
+
this.createModule = createModule || defaultCreateModule;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Object} params
|
|
62
|
+
* @param {string} params.entityType
|
|
63
|
+
* @param {number} [params.step=1]
|
|
64
|
+
* @param {string} [params.userId]
|
|
65
|
+
* @param {string} [params.state] - OAuth state forwarded to the Api.
|
|
66
|
+
* @returns {Promise<{type: string, data: Object, step: number, totalSteps: number, isMultiStep: boolean}>}
|
|
67
|
+
*/
|
|
68
|
+
async execute({ entityType, step = 1, userId = null, state } = {}) {
|
|
69
|
+
const definition = findDefinition(this.moduleDefinitions, entityType);
|
|
70
|
+
const totalSteps = getAuthStepCount(definition);
|
|
71
|
+
|
|
72
|
+
if (!Number.isInteger(step) || step < 1 || step > totalSteps) {
|
|
73
|
+
throw Boom.badRequest(
|
|
74
|
+
`step must be between 1 and ${totalSteps} for ${entityType}`,
|
|
75
|
+
{ code: 'INVALID_STEP' }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let raw;
|
|
80
|
+
if (typeof definition.getAuthRequirementsForStep === 'function') {
|
|
81
|
+
raw = await definition.getAuthRequirementsForStep(step);
|
|
82
|
+
} else {
|
|
83
|
+
const module = await this.createModule({
|
|
84
|
+
userId,
|
|
85
|
+
definition,
|
|
86
|
+
...(state ? { state } : {}),
|
|
87
|
+
});
|
|
88
|
+
raw = await singleStepRequirements(definition, module);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...normalizeRequirements(raw),
|
|
93
|
+
step,
|
|
94
|
+
totalSteps,
|
|
95
|
+
isMultiStep: totalSteps > 1,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { GetAuthorizationRequirements, normalizeRequirements };
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
2
|
const { mapModuleClassToModuleDTO } = require('../utils/map-module-dto');
|
|
3
|
+
const { describeRecord, visibilityClauses } = require('../scope-visibility');
|
|
4
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
3
5
|
|
|
4
6
|
class GetEntitiesForUser {
|
|
5
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
7
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
8
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
6
9
|
this.moduleRepository = moduleRepository;
|
|
7
10
|
|
|
8
11
|
this.definitionMap = new Map();
|
|
@@ -11,21 +14,31 @@ class GetEntitiesForUser {
|
|
|
11
14
|
}
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The entities a `User` may see in the active organization (ADR-056 §3.6).
|
|
19
|
+
* @param {Object} user - authenticated User
|
|
20
|
+
*/
|
|
21
|
+
async execute(user) {
|
|
22
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
23
|
+
const found = await this.moduleRepository.findEntitiesByOwnership(
|
|
24
|
+
visibilityClauses(ctx, 'entity')
|
|
25
|
+
);
|
|
26
|
+
const entities = (found || []).filter((entity) =>
|
|
27
|
+
describeRecord(entity, ctx, 'entity')
|
|
17
28
|
);
|
|
18
29
|
|
|
19
|
-
return
|
|
20
|
-
|
|
30
|
+
return Promise.all(
|
|
31
|
+
entities.map(async (entity) => {
|
|
32
|
+
const definition = this.definitionMap.get(entity.moduleName);
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
const moduleInstance = await Module.create({
|
|
35
|
+
userId: entity.userId,
|
|
36
|
+
definition: definition,
|
|
37
|
+
entity: entity,
|
|
38
|
+
});
|
|
39
|
+
return mapModuleClassToModuleDTO(moduleInstance);
|
|
40
|
+
})
|
|
41
|
+
);
|
|
29
42
|
}
|
|
30
43
|
}
|
|
31
44
|
|