@friggframework/core 2.0.0--canary.679.a2bcc2f.0 → 2.0.0--canary.686.87e9875.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 +25 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/user-commands.js +17 -5
- package/core/Worker.js +31 -7
- 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 +185 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +36 -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 +35 -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-entities-for-user.js +16 -5
- package/modules/use-cases/get-entity-module-for-user.js +32 -5
- package/modules/use-cases/get-entity-options-by-id.js +9 -3
- package/modules/use-cases/get-module.js +9 -3
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +39 -12
- package/modules/use-cases/process-authorization-callback.js +52 -18
- package/modules/use-cases/refresh-entity-options.js +9 -3
- package/modules/use-cases/test-module-auth.js +9 -3
- 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 +76 -0
- package/queues/providers/qstash-queue-provider.js +122 -0
- package/queues/providers/sqs-queue-provider.js +61 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +46 -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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { Module } = require('../module');
|
|
3
|
+
|
|
4
|
+
const sameId = (a, b) =>
|
|
5
|
+
a !== undefined && a !== null && b !== undefined && b !== null &&
|
|
6
|
+
String(a) === String(b);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates (or finds) the entity for a credential the caller already holds:
|
|
10
|
+
* the v1 `POST /api/entity`. The owner and organization follow the scope of
|
|
11
|
+
* `entityType`, exactly as an authorization does (ADR-056 §3.2, §3.6), so the
|
|
12
|
+
* owner is decided by `AuthorizeEntityForUser.resolveOwnership` and never by
|
|
13
|
+
* `user.getId()`. Whatever that resolution refuses (a missing identity, and
|
|
14
|
+
* an organization-level connection by a non-admin) is refused here too.
|
|
15
|
+
*
|
|
16
|
+
* The credential must belong to that owner: an organization entity is built
|
|
17
|
+
* on the organization's credential, an individual one on the person's own.
|
|
18
|
+
*/
|
|
19
|
+
class CreateEntityFromCredential {
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} params
|
|
22
|
+
* @param {import('./authorize-entity-for-user').AuthorizeEntityForUser} params.authorizeEntityForUser
|
|
23
|
+
* @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback
|
|
24
|
+
* @param {Object} params.credentialRepository
|
|
25
|
+
* @param {Array<Object>} params.moduleDefinitions
|
|
26
|
+
* @param {Function} [params.createModule] - builds a Module; injectable for tests
|
|
27
|
+
*/
|
|
28
|
+
constructor({
|
|
29
|
+
authorizeEntityForUser,
|
|
30
|
+
processAuthorizationCallback,
|
|
31
|
+
credentialRepository,
|
|
32
|
+
moduleDefinitions,
|
|
33
|
+
createModule,
|
|
34
|
+
}) {
|
|
35
|
+
this.authorizeEntityForUser = authorizeEntityForUser;
|
|
36
|
+
this.processAuthorizationCallback = processAuthorizationCallback;
|
|
37
|
+
this.credentialRepository = credentialRepository;
|
|
38
|
+
this.moduleDefinitions = moduleDefinitions || [];
|
|
39
|
+
this.createModule = createModule || ((params) => new Module(params));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {Object} params
|
|
44
|
+
* @param {Object} params.user - authenticated User
|
|
45
|
+
* @param {string} params.entityType
|
|
46
|
+
* @param {string} params.credentialId
|
|
47
|
+
* @returns {Promise<Object>} the persisted entity
|
|
48
|
+
*/
|
|
49
|
+
async execute({ user, entityType, credentialId }) {
|
|
50
|
+
const definition = this.moduleDefinitions.find(
|
|
51
|
+
(def) =>
|
|
52
|
+
def.moduleName === entityType ||
|
|
53
|
+
(typeof def.getName === 'function' && def.getName() === entityType)
|
|
54
|
+
);
|
|
55
|
+
if (!definition) {
|
|
56
|
+
throw Boom.badRequest(`Unknown entity type ${entityType}`, {
|
|
57
|
+
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ownership = this.authorizeEntityForUser.resolveOwnership({
|
|
62
|
+
user,
|
|
63
|
+
entityType,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const credential =
|
|
67
|
+
await this.credentialRepository.findCredentialById(credentialId);
|
|
68
|
+
// A missing credential and someone else's are the same answer, so ids
|
|
69
|
+
// cannot be probed.
|
|
70
|
+
if (!credential || !sameId(credential.userId, ownership.ownerId)) {
|
|
71
|
+
throw Boom.badRequest('Invalid credential ID', {
|
|
72
|
+
code: 'INVALID_CREDENTIAL',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Built for the owner, with the credential loaded (the repository
|
|
77
|
+
// returns its tokens flat; a Module reads them from `data`), so the
|
|
78
|
+
// API can call the provider and a token refresh during
|
|
79
|
+
// getEntityDetails updates the owner's credential.
|
|
80
|
+
const { id, userId, externalId, authIsValid, ...data } = credential;
|
|
81
|
+
const module = this.createModule({
|
|
82
|
+
userId: ownership.ownerId,
|
|
83
|
+
definition,
|
|
84
|
+
entity: {
|
|
85
|
+
credential: { id, userId, externalId, authIsValid, data },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const entityDetails =
|
|
89
|
+
await definition.requiredAuthMethods.getEntityDetails(
|
|
90
|
+
module.api,
|
|
91
|
+
null,
|
|
92
|
+
null,
|
|
93
|
+
ownership.ownerId
|
|
94
|
+
);
|
|
95
|
+
Object.assign(
|
|
96
|
+
entityDetails.details,
|
|
97
|
+
module.apiParamsFromEntity(module.api)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return this.processAuthorizationCallback.findOrCreateEntity(
|
|
101
|
+
entityDetails,
|
|
102
|
+
definition.moduleName,
|
|
103
|
+
credential.id,
|
|
104
|
+
{
|
|
105
|
+
scope: ownership.scope,
|
|
106
|
+
organizationId: ownership.organizationId,
|
|
107
|
+
legacyOrgId: ownership.legacyOrgId,
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { CreateEntityFromCredential };
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const {
|
|
3
|
+
describeRecord,
|
|
4
|
+
visibilityClauses,
|
|
5
|
+
matchesListFilters,
|
|
6
|
+
} = require('../scope-visibility');
|
|
7
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
8
|
+
const { assertCanManage } = require('./manage-access');
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* Management API v2 entity operations. Each one starts from
|
|
@@ -6,29 +13,55 @@ const Boom = require('@hapi/boom');
|
|
|
6
13
|
* (missing or foreign entity = 404).
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The entities the caller may see in the active organization (ADR-056 §3.4,
|
|
18
|
+
* §3.6): the organization's own, and the caller's individual ones by the
|
|
19
|
+
* visibility rule. Each carries `tenancy` ({scope, owner, organizationId,
|
|
20
|
+
* access}); the stored entity is otherwise untouched.
|
|
21
|
+
*/
|
|
10
22
|
class ListEntitiesForUser {
|
|
11
|
-
constructor({ moduleRepository }) {
|
|
23
|
+
constructor({ moduleRepository, scopePolicy }) {
|
|
12
24
|
this.moduleRepository = moduleRepository;
|
|
25
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
13
26
|
}
|
|
14
27
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
/**
|
|
29
|
+
* @param {Object} user - authenticated User
|
|
30
|
+
* @param {{scope?: string, owner?: string}} [filters] - `?scope=` and `?owner=`
|
|
31
|
+
*/
|
|
32
|
+
async execute(user, filters = {}) {
|
|
33
|
+
const ctx = this.scopePolicy.accessContext(user);
|
|
34
|
+
const entities = await this.moduleRepository.findEntitiesByOwnership(
|
|
35
|
+
visibilityClauses(ctx, 'entity')
|
|
19
36
|
);
|
|
20
|
-
|
|
37
|
+
const listed = [];
|
|
38
|
+
for (const entity of Array.isArray(entities) ? entities : []) {
|
|
39
|
+
// The query is the filter; this is the same rule applied again,
|
|
40
|
+
// so a repository that returns too much cannot widen the list.
|
|
41
|
+
const tenancy = describeRecord(entity, ctx, 'entity');
|
|
42
|
+
if (tenancy && matchesListFilters(tenancy, filters)) {
|
|
43
|
+
listed.push({ ...entity, tenancy });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return listed;
|
|
21
47
|
}
|
|
22
48
|
}
|
|
23
49
|
|
|
24
|
-
/** One entity the caller
|
|
50
|
+
/** One entity the caller may use, with its `tenancy`. */
|
|
25
51
|
class GetEntityForUser {
|
|
26
52
|
constructor({ getEntityModuleForUser }) {
|
|
27
53
|
this.getEntityModuleForUser = getEntityModuleForUser;
|
|
28
54
|
}
|
|
29
55
|
|
|
30
56
|
async execute(entityId, user) {
|
|
31
|
-
|
|
57
|
+
const entity = await this.getEntityModuleForUser.loadOwnedEntity(
|
|
58
|
+
entityId,
|
|
59
|
+
user
|
|
60
|
+
);
|
|
61
|
+
return {
|
|
62
|
+
...entity,
|
|
63
|
+
tenancy: this.getEntityModuleForUser.describe(entity, user),
|
|
64
|
+
};
|
|
32
65
|
}
|
|
33
66
|
}
|
|
34
67
|
|
|
@@ -79,10 +112,10 @@ class RefreshEntityOptionsForUser {
|
|
|
79
112
|
}
|
|
80
113
|
|
|
81
114
|
/**
|
|
82
|
-
* Deletes an entity the caller
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* /api/v2/credentials.
|
|
115
|
+
* Deletes an entity the caller may manage (the owner; for an organization
|
|
116
|
+
* entity, an organization admin). Refuses with 409 while integrations still
|
|
117
|
+
* use it, so an integration never points at a missing entity. The credential
|
|
118
|
+
* is left alone (others may share it); delete it through /api/v2/credentials.
|
|
86
119
|
*/
|
|
87
120
|
class DeleteEntityForUser {
|
|
88
121
|
constructor({ getEntityModuleForUser, moduleRepository, integrationRepository }) {
|
|
@@ -96,6 +129,10 @@ class DeleteEntityForUser {
|
|
|
96
129
|
entityId,
|
|
97
130
|
user
|
|
98
131
|
);
|
|
132
|
+
assertCanManage(
|
|
133
|
+
this.getEntityModuleForUser.describe(entity, user)?.access,
|
|
134
|
+
`entity ${entity.id}`
|
|
135
|
+
);
|
|
99
136
|
const integrations =
|
|
100
137
|
(await this.integrationRepository.findIntegrationsByEntityId(
|
|
101
138
|
entity.id
|
|
@@ -59,10 +59,31 @@ function findDefinition(moduleDefinitions, entityType) {
|
|
|
59
59
|
return definition;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
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) => ({
|
|
63
83
|
// The registry key is the name the API uses, whatever the definition spells.
|
|
64
84
|
...describeEntityType(entry.definition, name),
|
|
65
85
|
usedBy: usedByOf(entry),
|
|
86
|
+
...scopeOf(scopePolicy, name, user),
|
|
66
87
|
});
|
|
67
88
|
|
|
68
89
|
function requireRegistry(entityTypeRegistry) {
|
|
@@ -78,31 +99,34 @@ function requireRegistry(entityTypeRegistry) {
|
|
|
78
99
|
* bind each type; it is empty for a registry-only type.
|
|
79
100
|
*/
|
|
80
101
|
class ListEntityTypes {
|
|
81
|
-
constructor({ entityTypeRegistry }) {
|
|
102
|
+
constructor({ entityTypeRegistry, scopePolicy }) {
|
|
82
103
|
this.registry = requireRegistry(entityTypeRegistry);
|
|
104
|
+
this.scopePolicy = scopePolicy;
|
|
83
105
|
}
|
|
84
106
|
|
|
85
|
-
|
|
107
|
+
/** @param {{user?: Object}} [params] - the caller, for the level-6 scope default */
|
|
108
|
+
async execute({ user } = {}) {
|
|
86
109
|
return [...this.registry]
|
|
87
|
-
.map(([name, entry]) => describeEntry(name, entry))
|
|
110
|
+
.map(([name, entry]) => describeEntry(name, entry, this.scopePolicy, user))
|
|
88
111
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
89
112
|
}
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
/** Describes one entity type, looked up by its registry key; 404 when absent. */
|
|
93
116
|
class GetEntityType {
|
|
94
|
-
constructor({ entityTypeRegistry }) {
|
|
117
|
+
constructor({ entityTypeRegistry, scopePolicy }) {
|
|
95
118
|
this.registry = requireRegistry(entityTypeRegistry);
|
|
119
|
+
this.scopePolicy = scopePolicy;
|
|
96
120
|
}
|
|
97
121
|
|
|
98
|
-
async execute(entityType) {
|
|
122
|
+
async execute(entityType, { user } = {}) {
|
|
99
123
|
const entry = this.registry.get(entityType);
|
|
100
124
|
if (!entry) {
|
|
101
125
|
throw Boom.notFound(`Entity type '${entityType}' not found`, {
|
|
102
126
|
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
103
127
|
});
|
|
104
128
|
}
|
|
105
|
-
return describeEntry(entityType, entry);
|
|
129
|
+
return describeEntry(entityType, entry, this.scopePolicy, user);
|
|
106
130
|
}
|
|
107
131
|
}
|
|
108
132
|
|
|
@@ -2,6 +2,8 @@ const Boom = require('@hapi/boom');
|
|
|
2
2
|
const { getLogger } = require('../../logs');
|
|
3
3
|
const { getTelemetry } = require('../../telemetry/telemetry-runtime');
|
|
4
4
|
const { ProxyRequestError } = require('../proxy/proxy-errors');
|
|
5
|
+
const { SCOPES } = require('../scope-resolution');
|
|
6
|
+
const { grantsUse } = require('../scope-visibility');
|
|
5
7
|
const { normalizeProxyPath, matchAllowRule } = require('../proxy/proxy-path');
|
|
6
8
|
const {
|
|
7
9
|
validateProxyRequest,
|
|
@@ -55,9 +57,11 @@ const authHeaderNamesOf = (api) =>
|
|
|
55
57
|
* through its module with the stored credential, inside the limits the app
|
|
56
58
|
* and the module set.
|
|
57
59
|
*
|
|
58
|
-
* Order of checks: request shape and size, path rules, entity
|
|
59
|
-
* (404),
|
|
60
|
-
*
|
|
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,
|
|
61
65
|
* allowed or denied, writes one audit record and one usage count. Bodies,
|
|
62
66
|
* headers, query values and the raw path are never logged.
|
|
63
67
|
*/
|
|
@@ -98,9 +102,14 @@ class ExecuteEntityProxyRequest {
|
|
|
98
102
|
audit.requestBytes = validated.requestBytes;
|
|
99
103
|
const path = normalizeProxyPath(validated.path);
|
|
100
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.
|
|
101
108
|
const { entity, definition, module } =
|
|
102
109
|
await this.getEntityModuleForUser.execute(entityId, user);
|
|
103
110
|
audit.module = definition.moduleName;
|
|
111
|
+
this.assertProxyable(entityId, entity, definition, user);
|
|
112
|
+
|
|
104
113
|
const credentialRef = entity.credential;
|
|
105
114
|
const credentialId =
|
|
106
115
|
credentialRef && typeof credentialRef === 'object'
|
|
@@ -202,6 +211,44 @@ class ExecuteEntityProxyRequest {
|
|
|
202
211
|
}
|
|
203
212
|
}
|
|
204
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
|
+
|
|
205
252
|
/** A module may pin its proxy base URL (string, or a function of the api). */
|
|
206
253
|
resolveBaseUrl(definition, api) {
|
|
207
254
|
const declared = definition.proxy?.baseUrl;
|
|
@@ -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,16 +14,24 @@ 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
30
|
return entities.map((entity) => {
|
|
20
31
|
const definition = this.definitionMap.get(entity.moduleName);
|
|
21
32
|
|
|
22
33
|
const moduleInstance = new Module({
|
|
23
|
-
userId,
|
|
34
|
+
userId: entity.userId,
|
|
24
35
|
definition: definition,
|
|
25
36
|
entity: entity,
|
|
26
37
|
});
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
+
const { describeRecord } = require('../scope-visibility');
|
|
3
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
4
|
+
const { isEntityNotFound } = require('../repositories/entity-not-found');
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* True when `user` may act on records owned by `ownerId`. Uses the User
|
|
5
8
|
* domain object's rule (individual or organisation user model) when present.
|
|
9
|
+
* This is the owner-only rule: it ignores the organization a record belongs
|
|
10
|
+
* to, so it no longer decides access to an entity or an integration
|
|
11
|
+
* (modules/scope-visibility.js does). Credentials still use it.
|
|
6
12
|
* @param {Object} user - authenticated User (or a plain { getId } double)
|
|
7
13
|
* @param {string|number} ownerId
|
|
8
14
|
*/
|
|
@@ -30,8 +36,11 @@ class GetEntityModuleForUser {
|
|
|
30
36
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
31
37
|
* @param {Array<Object>} params.moduleDefinitions
|
|
32
38
|
* @param {Function} [params.createModule] - builds a Module; injectable for tests.
|
|
39
|
+
* @param {Object} [params.scopePolicy] - from createScopePolicy; with none the
|
|
40
|
+
* app configures nothing (ADR-056 §3.1).
|
|
33
41
|
*/
|
|
34
|
-
constructor({ moduleRepository, moduleDefinitions, createModule }) {
|
|
42
|
+
constructor({ moduleRepository, moduleDefinitions, createModule, scopePolicy }) {
|
|
43
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
35
44
|
this.moduleRepository = moduleRepository;
|
|
36
45
|
this.moduleDefinitions = moduleDefinitions || [];
|
|
37
46
|
this.createModule = createModule || defaultCreateModule;
|
|
@@ -61,26 +70,44 @@ class GetEntityModuleForUser {
|
|
|
61
70
|
return { entity, definition, module };
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* The entity record, or a 404 when it is missing or the caller may not use
|
|
75
|
+
* it: not theirs, or paired with another organization (ADR-056 §3.6). A
|
|
76
|
+
* `global` entity is never the tenant's to load.
|
|
77
|
+
*/
|
|
65
78
|
async loadOwnedEntity(entityId, user) {
|
|
66
79
|
let entity = null;
|
|
67
80
|
try {
|
|
68
81
|
entity = await this.moduleRepository.findEntityById(entityId);
|
|
69
82
|
} catch (error) {
|
|
70
|
-
//
|
|
71
|
-
|
|
83
|
+
// Every adapter throws for a missing row, and a malformed id (e.g.
|
|
84
|
+
// not an integer on Postgres) is "not found" too. Both must be the
|
|
85
|
+
// 404 another tenant's entity gets, or the status says which ids
|
|
86
|
+
// exist. Anything else is a real failure.
|
|
87
|
+
if (
|
|
88
|
+
isEntityNotFound(error) ||
|
|
89
|
+
/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')
|
|
90
|
+
) {
|
|
72
91
|
entity = null;
|
|
73
92
|
} else {
|
|
74
93
|
throw error;
|
|
75
94
|
}
|
|
76
95
|
}
|
|
77
|
-
if (!entity || !
|
|
96
|
+
if (!entity || !this.describe(entity, user)) {
|
|
78
97
|
throw Boom.notFound(`Entity ${entityId} not found`, {
|
|
79
98
|
code: 'ENTITY_NOT_FOUND',
|
|
80
99
|
});
|
|
81
100
|
}
|
|
82
101
|
return entity;
|
|
83
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `{ scope, owner, organizationId, access }` of an entity to this caller,
|
|
106
|
+
* or null when they may not see it (§3.4).
|
|
107
|
+
*/
|
|
108
|
+
describe(entity, user) {
|
|
109
|
+
return describeRecord(entity, this.scopePolicy.accessContext(user), 'entity');
|
|
110
|
+
}
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
module.exports = { GetEntityModuleForUser, userOwns };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class GetEntityOptionsById {
|
|
4
5
|
/**
|
|
@@ -6,9 +7,11 @@ class GetEntityOptionsById {
|
|
|
6
7
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
7
8
|
* @param {} params.moduleDefinitions
|
|
8
9
|
*/
|
|
9
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
10
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
10
11
|
this.moduleRepository = moduleRepository;
|
|
11
12
|
this.moduleDefinitions = moduleDefinitions;
|
|
13
|
+
// Who may load an entity follows its scope (ADR-056 §3.6).
|
|
14
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -35,7 +38,7 @@ class GetEntityOptionsById {
|
|
|
35
38
|
|
|
36
39
|
// Validate entity ownership
|
|
37
40
|
const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
|
|
38
|
-
?
|
|
41
|
+
? this.scopePolicy.canUseEntity(userIdOrUser, entity)
|
|
39
42
|
: entity.userId?.toString() === userId?.toString();
|
|
40
43
|
|
|
41
44
|
if (!isOwned) {
|
|
@@ -57,8 +60,11 @@ class GetEntityOptionsById {
|
|
|
57
60
|
);
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
64
|
+
// organization's entity, and a token refresh during this call must
|
|
65
|
+
// update the owner's credential, not create one under the caller.
|
|
60
66
|
const module = new Module({
|
|
61
|
-
userId,
|
|
67
|
+
userId: entity.userId ?? userId,
|
|
62
68
|
entity,
|
|
63
69
|
definition: moduleDefinition,
|
|
64
70
|
});
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class GetModule {
|
|
4
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
5
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
5
6
|
this.moduleRepository = moduleRepository;
|
|
6
7
|
this.moduleDefinitions = moduleDefinitions;
|
|
8
|
+
// Who may load an entity follows its scope (ADR-056 §3.6).
|
|
9
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -32,7 +35,7 @@ class GetModule {
|
|
|
32
35
|
// If User object provided, use ownsUserId to check linked users
|
|
33
36
|
// Otherwise fall back to simple equality check
|
|
34
37
|
const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
|
|
35
|
-
?
|
|
38
|
+
? this.scopePolicy.canUseEntity(userIdOrUser, entity)
|
|
36
39
|
: entity.userId?.toString() === userId?.toString();
|
|
37
40
|
|
|
38
41
|
if (!isOwned) {
|
|
@@ -53,8 +56,11 @@ class GetModule {
|
|
|
53
56
|
);
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
60
|
+
// organization's entity, and a token refresh during this call must
|
|
61
|
+
// update the owner's credential, not create one under the caller.
|
|
56
62
|
const module = new Module({
|
|
57
|
-
userId,
|
|
63
|
+
userId: entity.userId ?? userId,
|
|
58
64
|
entity,
|
|
59
65
|
definition: moduleDefinition,
|
|
60
66
|
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { ACCESS } = require('../scope-resolution');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one check behind every "may this caller change or delete it" gate
|
|
6
|
+
* (ADR-056 §3 permissions): the caller may use it but not manage it, so 403
|
|
7
|
+
* `ORG_ADMIN_REQUIRED`. `access` comes from describeRecord (entities,
|
|
8
|
+
* integrations) or scopePolicy.accessToOwnedBy (credentials).
|
|
9
|
+
*
|
|
10
|
+
* @param {string|null} access - ACCESS value
|
|
11
|
+
* @param {string} subject - e.g. `entity 5`, for the message
|
|
12
|
+
*/
|
|
13
|
+
function assertCanManage(access, subject) {
|
|
14
|
+
if (access !== ACCESS.MANAGE) {
|
|
15
|
+
throw Boom.forbidden(`You may use ${subject} but not change or delete it`, {
|
|
16
|
+
code: 'ORG_ADMIN_REQUIRED',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { assertCanManage };
|