From b7a5ae6762a4a72ed3212d6078c8e54e82a6ad9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:03:34 +0000 Subject: [PATCH 1/7] feat(core): app-level entity registry (ADR-059) Add getEntityTypeRegistry(appDefinition), a pure function that merges the optional appDefinition.entities map with the modules bound by integrations. Inference stays on unless entities.$infer is false. With no entities key nothing new can fail: two different definitions under one moduleName keep the first and log a warning. With entities present (even {}) the rules are strict: a key must equal moduleName and getName(), one name is one definition (same object, or same package@version), and $infer: false requires every bound module to be registered. loadAppDefinition builds the registry once, at load, so errors fail fast, and returns entityTypeRegistry and moduleDefinitions. The v1 router, v2 dependencies, health, the webhook loader and the worker entry read those; createIntegrationCommands and createQueueWorker take moduleDefinitions and fall back to the integration's own modules when none is given. getModulesDefinitionFromIntegrationClasses is removed. GET /api/v2/entities/types[/:type] lists registry-only types, looks them up by registry key, and each description gains usedBy. app-definition.schema.json gains entities; types/modules/index.d.ts adds the registry types. This commit does not change which fields are encrypted: loadModuleEncryptionSchemas keeps its existing (broken) require path, with a comment pointing to claude/internal-mcp-03b-module-credential-encryption. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- docs/reference/api-reference.md | 4 +- docs/reference/core-concepts.md | 29 + ...tegration-commands.entity-registry.test.js | 67 +++ .../commands/integration-commands.js | 26 +- packages/core/application/index.js | 8 +- .../encryption/encryption-schema-registry.js | 7 + .../core/handlers/app-definition-loader.js | 25 +- .../handlers/app-definition-loader.test.js | 70 +++ .../backend-utils.entity-registry.test.js | 144 +++++ packages/core/handlers/backend-utils.js | 56 +- packages/core/handlers/backend-utils.test.js | 5 +- packages/core/handlers/routers/auth.test.js | 7 +- .../routers/health.entity-registry.test.js | 145 +++++ packages/core/handlers/routers/health.js | 5 +- packages/core/handlers/routers/health.test.js | 11 +- .../workers/integration-defined-workers.js | 7 +- ...tegration-defined-workers.registry.test.js | 36 ++ packages/core/index.js | 6 +- packages/core/integrations/index.js | 4 - .../core/integrations/integration-router.js | 39 +- .../integration-router-authorize-logs.test.js | 2 +- ...integration-router-entity-registry.test.js | 148 +++++ .../integrations/utils/map-integration-dto.js | 16 +- packages/core/management-api/contract.test.js | 2 +- .../core/management-api/openapi/components.js | 8 +- .../management-api/openapi/v2-operations.js | 4 + .../core/management-api/v2/dependencies.js | 29 +- .../management-api/v2/entity-registry.test.js | 199 +++++++ packages/core/modules/entity-type-registry.js | 311 ++++++++++ .../core/modules/entity-type-registry.test.js | 544 ++++++++++++++++++ packages/core/modules/index.js | 6 + .../core/modules/use-cases/entity-types.js | 48 +- .../modules/use-cases/entity-types.test.js | 100 +++- packages/core/types/index.d.ts | 1 + packages/core/types/modules/index.d.ts | 58 ++ .../schemas/schemas/api-entities.schema.json | 5 + .../schemas/app-definition.schema.json | 50 ++ .../tests/app-definition-entities.test.js | 121 ++++ 38 files changed, 2222 insertions(+), 131 deletions(-) create mode 100644 packages/core/application/commands/integration-commands.entity-registry.test.js create mode 100644 packages/core/handlers/backend-utils.entity-registry.test.js create mode 100644 packages/core/handlers/routers/health.entity-registry.test.js create mode 100644 packages/core/handlers/workers/integration-defined-workers.registry.test.js create mode 100644 packages/core/integrations/tests/integration-router-entity-registry.test.js create mode 100644 packages/core/management-api/v2/entity-registry.test.js create mode 100644 packages/core/modules/entity-type-registry.js create mode 100644 packages/core/modules/entity-type-registry.test.js create mode 100644 packages/core/types/modules/index.d.ts create mode 100644 packages/schemas/tests/app-definition-entities.test.js diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md index 59c1a0333..d878deaaf 100644 --- a/docs/reference/api-reference.md +++ b/docs/reference/api-reference.md @@ -66,8 +66,8 @@ No authentication and no database access. Clients call it first. | POST | `/api/v2/integrations/:integrationId/actions/:actionId` | Only user actions; others are `404 ACTION_NOT_FOUND` | | GET | `/api/v2/integrations/:integrationId/test-auth` | `{ status: "ok" }` or `{ status: "failed", errors }` | | GET | `/api/v2/entities` | `{ entities }`; never credential fields | -| GET | `/api/v2/entities/types` | `{ types }`: modules the app can connect | -| GET | `/api/v2/entities/types/:entityType` | | +| GET | `/api/v2/entities/types` | `{ types }`: modules the app can connect (registered in `entities` or bound by an integration). Each has `usedBy`, the integrations that bind it; empty for a registry-only type | +| GET | `/api/v2/entities/types/:entityType` | Same shape, with `usedBy` | | GET | `/api/v2/entities/types/:entityType/requirements` | `?step=`; read-only, no session | | GET / DELETE | `/api/v2/entities/:entityId` | DELETE is `409 ENTITY_IN_USE` while integrations use it | | GET | `/api/v2/entities/:entityId/test-auth` | `{ status: "ok" \| "failed" }` | diff --git a/docs/reference/core-concepts.md b/docs/reference/core-concepts.md index 49cba40f7..6c900fe47 100644 --- a/docs/reference/core-concepts.md +++ b/docs/reference/core-concepts.md @@ -52,6 +52,35 @@ const appDefinition = { }; ``` +## Entity registry + +An app offers the entity types (API modules) its integrations bind. To let people connect a module that no integration binds, such as an agent's tools, a "Connect HubSpot" step before any sync, or a global service account, register it under `entities`: + +```javascript +const appDefinition = { + entities: { + google: { definition: GoogleWorkspaceDefinition, scope: 'individual' }, + hubspot: { definition: HubSpotDefinition }, + twilio: { definition: TwilioDefinition, scope: 'global' }, + }, + integrations: [HubSpotSyncIntegration], +}; +``` + +- The key is the entity type name and must equal the definition's `moduleName`, and its `getName()` when it has one. Case matters. A mismatch stops the app at load. +- Each value is `{ definition, scope?, proxy?, display? }`. `scope` is `individual`, `organization` or `global`, or `{ default, allow: [...] }`. +- Modules bound by `integrations` are still inferred, so an app without `entities` behaves as before. The offered set is the registry entries plus every bound module, and the same set feeds `/api/authorize`, `/api/v2/entities/types`, the health checks and the workers. +- **Backward compatibility.** With no `entities` key, nothing new can fail: if two integrations bind different definition objects under one `moduleName`, the first is kept and a warning is logged (`frigg.modules.entity_registry.duplicate_definition`). The strict rules below apply once `entities` is present, even as `{}`. +- With `entities` present, the same name must always mean the same definition: the same object, or the same `packageName` and `version`. A registry entry and an integration (or two integrations) that supply different definitions under one name stop the app at load, naming both sources. Two integrations sharing one definition is fine. +- `entities: { $infer: false, ... }` turns inference off. Every module an integration binds must then be registered, and the app lists the ones that are not when it fails to load. +- **Fail fast.** `loadAppDefinition()` builds the registry once, so every error above surfaces when the app definition loads, not on the first request. The loaded definition carries it as `entityTypeRegistry` and `moduleDefinitions`; handlers and workers read those. +- A bound definition with no `moduleName` and no `getName()` is still offered (de-duplicated by identity) while `$infer` is true. With `$infer: false` it cannot be registered, so the app fails to load. +- `GET /api/v2/entities/types` lists each type with `usedBy`, the integrations that bind it (empty for a registry-only type). + +In code, `getEntityTypeRegistry(appDefinition)` (`packages/core/modules/entity-type-registry.js`, exported from `@friggframework/core`) returns `name -> { definition, sources, settings }`. `sources` holds `{ kind: 'app' }` and one `{ kind: 'integration', name }` per binding integration. Invalid registries throw `EntityRegistryError` (`code: 'ENTITY_REGISTRY_INVALID'`). `createFriggCommands` takes an optional `moduleDefinitions`; without it the commands hydrate with the integration's own modules only. + +Decision record: ADR-059, app-level entity registry. + ## Testing Frigg supports automated testing with tools like Jest to maintain reliability and performance. diff --git a/packages/core/application/commands/integration-commands.entity-registry.test.js b/packages/core/application/commands/integration-commands.entity-registry.test.js new file mode 100644 index 000000000..5cc4637b3 --- /dev/null +++ b/packages/core/application/commands/integration-commands.entity-registry.test.js @@ -0,0 +1,67 @@ +/** + * ADR-059 at integration commands: the module factory for an integration + * class is built from the app registry. Real loader and registry; only the + * repositories and the factory (to read what it was given) are doubles. + */ +jest.mock('../../database/config', () => ({ + DB_TYPE: 'mongodb', + getDatabaseType: jest.fn(() => 'mongodb'), + PRISMA_LOG_LEVEL: 'error,warn', +})); +jest.mock('../../modules/module-factory', () => ({ + ModuleFactory: jest.fn(() => ({})), +})); +jest.mock( + '../../integrations/repositories/integration-repository-factory', + () => ({ + createIntegrationRepository: jest.fn(() => ({})), + }) +); +jest.mock('../../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: jest.fn(() => ({})), +})); + +const { ModuleFactory } = require('../../modules/module-factory'); +const { createIntegrationCommands } = require('./integration-commands'); +const { + DummyIntegration, +} = require('../../integrations/tests/doubles/dummy-integration-class'); + +describe('createIntegrationCommands and the entity registry (ADR-059)', () => { + afterEach(() => jest.clearAllMocks()); + + it('gives the factory the module definitions it is handed (the app registry)', () => { + const registryOnly = { moduleName: 'xero' }; + const moduleDefinitions = [registryOnly, { moduleName: 'hubspot' }]; + + createIntegrationCommands({ + integrationClass: DummyIntegration, + moduleDefinitions, + }); + + expect(ModuleFactory.mock.calls[0][0].moduleDefinitions).toBe( + moduleDefinitions + ); + }); + + it('without any, hydrates with the integration’s own modules only (explicit fallback)', () => { + createIntegrationCommands({ integrationClass: DummyIntegration }); + + const { moduleDefinitions } = ModuleFactory.mock.calls[0][0]; + expect(moduleDefinitions.map((d) => d.getName())).toEqual(['dummy']); + }); + + it('createFriggCommands passes them through', () => { + const { createFriggCommands } = require('../index'); + const moduleDefinitions = [{ moduleName: 'xero' }]; + + createFriggCommands({ + integrationClass: DummyIntegration, + moduleDefinitions, + }); + + expect(ModuleFactory.mock.calls[0][0].moduleDefinitions).toBe( + moduleDefinitions + ); + }); +}); diff --git a/packages/core/application/commands/integration-commands.js b/packages/core/application/commands/integration-commands.js index fed6186e2..18375c2a9 100644 --- a/packages/core/application/commands/integration-commands.js +++ b/packages/core/application/commands/integration-commands.js @@ -30,8 +30,9 @@ const { PatchIntegrationConfig, } = require('../../integrations/use-cases/patch-integration-config'); const { - getModulesDefinitionFromIntegrationClasses, -} = require('../../integrations/utils/map-integration-dto'); + getEntityTypeRegistry, + getEntityTypeDefinitions, +} = require('../../modules/entity-type-registry'); const ERROR_CODE_MAP = { ENTITY_NOT_FOUND: 401, @@ -51,7 +52,16 @@ function mapErrorToResponse(error) { }; } -function createIntegrationCommands({ integrationClass } = {}) { +/** + * @param {Object} [params] + * @param {Function} [params.integrationClass] + * @param {Array} [params.moduleDefinitions] - the app's offered module + * definitions (`loadAppDefinition().moduleDefinitions`). Integration code + * calls this from a constructor with no app definition in hand, so when it + * is omitted the commands hydrate with this integration's own modules only + * (a registry built from `{ integrations: [integrationClass] }`). + */ +function createIntegrationCommands({ integrationClass, moduleDefinitions } = {}) { // Always use Frigg's default repositories and use cases const integrationRepository = createIntegrationRepository(); @@ -108,13 +118,13 @@ function createIntegrationCommands({ integrationClass } = {}) { const moduleRepository = createModuleRepository(); - const moduleDefinitions = getModulesDefinitionFromIntegrationClasses([ - integrationClass, - ]); - const moduleFactory = new ModuleFactory({ moduleRepository, - moduleDefinitions, + moduleDefinitions: + moduleDefinitions ?? + getEntityTypeDefinitions( + getEntityTypeRegistry({ integrations: [integrationClass] }) + ), }); const loadIntegrationContextUseCase = new LoadIntegrationContextUseCase({ diff --git a/packages/core/application/index.js b/packages/core/application/index.js index c2b52d6a8..e94b760c7 100644 --- a/packages/core/application/index.js +++ b/packages/core/application/index.js @@ -17,6 +17,7 @@ const { createUsageCommands } = require('./commands/usage-commands'); * * @param {Object} params * @param {Object} params.integrationClass - Integration class (required) + * @param {Array} [params.moduleDefinitions] - the app's offered module definitions; defaults to the integration's own modules * @returns {Object} Unified commands object with all CRUD operations * * @example @@ -24,9 +25,12 @@ const { createUsageCommands } = require('./commands/usage-commands'); * const user = await commands.createUser({ username: 'user@example.com' }); * const credential = await commands.createCredential({ userId: user.id, ... }); */ -function createFriggCommands({ integrationClass }) { +function createFriggCommands({ integrationClass, moduleDefinitions }) { // All commands use Frigg's default repositories and use cases - const integrationCommands = createIntegrationCommands({ integrationClass }); + const integrationCommands = createIntegrationCommands({ + integrationClass, + moduleDefinitions, + }); const userCommands = createUserCommands(); diff --git a/packages/core/database/encryption/encryption-schema-registry.js b/packages/core/database/encryption/encryption-schema-registry.js index 1acbb3c49..e4c94173c 100644 --- a/packages/core/database/encryption/encryption-schema-registry.js +++ b/packages/core/database/encryption/encryption-schema-registry.js @@ -180,6 +180,13 @@ function loadModuleEncryptionSchemas(integrations) { return; } + // NOTE: this path does not exist ('../integrations' resolves to + // database/integrations), so today this function throws MODULE_NOT_FOUND, + // loadCustomEncryptionSchema swallows it, and module credentialFields are + // effectively NOT registered. Fixing it changes which fields are encrypted + // and needs a decrypt that tolerates plaintext, so it ships separately: + // branch claude/internal-mcp-03b-module-credential-encryption. Left as is + // here on purpose (ADR-059 must not change what gets encrypted). const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto'); const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations); diff --git a/packages/core/handlers/app-definition-loader.js b/packages/core/handlers/app-definition-loader.js index f525dd95e..35f54bf40 100644 --- a/packages/core/handlers/app-definition-loader.js +++ b/packages/core/handlers/app-definition-loader.js @@ -7,16 +7,15 @@ const { extractCredentialFieldsFromModules, } = require('../database/encryption/encryption-schema-registry'); const { - getModulesDefinitionFromIntegrationClasses, -} = require('../integrations/utils/map-integration-dto'); + getEntityTypeRegistry, + getEntityTypeDefinitions, +} = require('../modules/entity-type-registry'); // Handlers without a database never load the encryption registry, so the // credential leaf keys are registered for redaction here too. -function registerCredentialLogKeys(appDefinition, integrations) { +function registerCredentialLogKeys(appDefinition, moduleDefinitions) { try { - extractCredentialFieldsFromModules( - getModulesDefinitionFromIntegrationClasses(integrations) - ); + extractCredentialFieldsFromModules(moduleDefinitions); } catch { // An odd integration shape must not stop the app from loading. } @@ -33,7 +32,7 @@ function registerCredentialLogKeys(appDefinition, integrations) { * @function loadAppDefinition * @description Searches for the nearest backend package.json, loads the corresponding index.js file, * and extracts the application definition containing integrations and user configuration. - * @returns {{integrations: Array, userConfig: object | null, adminScripts: Array, reports: Array, admin: object, telemetry: object, logging: object | null, managementApi: object}} An object containing the application definition. + * @returns {{integrations: Array, entities: object | null, entityTypeRegistry: Map, moduleDefinitions: Array, userConfig: object | null, adminScripts: Array, reports: Array, admin: object, telemetry: object, logging: object | null, managementApi: object}} An object containing the application definition. * @throws {Error} Throws error if backend package.json cannot be found. * @throws {Error} Throws error if index.js file cannot be found in the backend directory. * @example @@ -57,6 +56,7 @@ function loadAppDefinition() { const { integrations = [], + entities = null, user: userConfig = null, adminScripts = [], reports = [], @@ -65,7 +65,13 @@ function loadAppDefinition() { managementApi = {}, } = appDefinition; - registerCredentialLogKeys(appDefinition, integrations); + // Fail fast: a bad `entities` registry (key/name mismatch, conflicting + // definitions, `$infer: false` with an unregistered module) must stop the + // app at load, not on the first request that needs a module. + const entityTypeRegistry = getEntityTypeRegistry(appDefinition); + const moduleDefinitions = getEntityTypeDefinitions(entityTypeRegistry); + + registerCredentialLogKeys(appDefinition, moduleDefinitions); // Degrade consistently: an invalid telemetry block must never take down a // router bundle that loads the app definition at module scope (telemetry is @@ -90,6 +96,9 @@ function loadAppDefinition() { return { integrations, + entities, + entityTypeRegistry, + moduleDefinitions, userConfig, adminScripts, reports, diff --git a/packages/core/handlers/app-definition-loader.test.js b/packages/core/handlers/app-definition-loader.test.js index cc38631df..d4208d1a4 100644 --- a/packages/core/handlers/app-definition-loader.test.js +++ b/packages/core/handlers/app-definition-loader.test.js @@ -8,6 +8,7 @@ jest.mock('@friggframework/core/utils', () => ({ const { findNearestBackendPackageJson } = require('@friggframework/core/utils'); const { loadAppDefinition } = require('./app-definition-loader'); +const { EntityRegistryError } = require('../modules/entity-type-registry'); const { isDeniedKey } = require('../logs/redact'); const { createMemorySink } = require('../logs'); @@ -124,4 +125,73 @@ describe('loadAppDefinition', () => { expect.objectContaining({ level: 'WARN', key: 'user_id' }), ]); }); + describe('entity registry (ADR-059)', () => { + const xero = { moduleName: 'xero' }; + const hubspot = { moduleName: 'hubspot' }; + const sync = { Definition: { name: 'sync', modules: { crm: { definition: hubspot } } } }; + + it('returns entities, the registry and the offered definitions, registry-only types included', () => { + dirs.push( + writeBackend({ + integrations: [sync], + entities: { xero: { definition: xero, scope: 'global' } }, + }) + ); + + const loaded = loadAppDefinition(); + + expect(loaded.entities).toEqual({ xero: { definition: xero, scope: 'global' } }); + expect([...loaded.entityTypeRegistry.keys()].sort()).toEqual(['hubspot', 'xero']); + expect(loaded.moduleDefinitions.map((d) => d.moduleName).sort()).toEqual([ + 'hubspot', + 'xero', + ]); + }); + + it('defaults entities to null and offers only integration-bound modules', () => { + dirs.push(writeBackend({ integrations: [sync] })); + + const loaded = loadAppDefinition(); + + expect(loaded.entities).toBeNull(); + expect(loaded.moduleDefinitions).toEqual([hubspot]); + }); + + it('registers a registry-only module’s credential fields as denied log keys', () => { + const registryOnly = { + moduleName: 'ledger', + encryption: { credentialFields: ['quokkaLedgerRef'] }, + }; + expect(isDeniedKey('quokkaLedgerRef')).toBe(false); + dirs.push( + writeBackend({ + integrations: [], + entities: { ledger: { definition: registryOnly } }, + }) + ); + + loadAppDefinition(); + + expect(isDeniedKey('quokkaLedgerRef')).toBe(true); + }); + + it.each([ + ['a key that is not the definition name', { entities: { crm: { definition: hubspot } } }, /key 'crm'/], + [ + '$infer: false with an unregistered bound module', + { entities: { $infer: false, xero: { definition: xero } } }, + /Missing: hubspot/, + ], + [ + 'a registered definition that conflicts with an integration binding', + { entities: { hubspot: { definition: { moduleName: 'hubspot', other: true } } } }, + /different definitions/, + ], + ])('fails at load, not on first request, for %s', (_label, extra, message) => { + dirs.push(writeBackend({ integrations: [sync], ...extra })); + + expect(() => loadAppDefinition()).toThrow(EntityRegistryError); + expect(() => loadAppDefinition()).toThrow(message); + }); + }); }); diff --git a/packages/core/handlers/backend-utils.entity-registry.test.js b/packages/core/handlers/backend-utils.entity-registry.test.js new file mode 100644 index 000000000..1321065d0 --- /dev/null +++ b/packages/core/handlers/backend-utils.entity-registry.test.js @@ -0,0 +1,144 @@ +/** + * ADR-059 at the webhook loader: the module factory it builds for an + * integration is given the app registry's definitions, registry-only types + * included. Real loader and registry; repositories and use cases are fakes. + */ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +jest.mock('@friggframework/core', () => ({ Worker: class {} })); +jest.mock('@friggframework/core/utils', () => ({ + findNearestBackendPackageJson: jest.fn(), +})); +jest.mock('./integration-event-dispatcher', () => ({ + IntegrationEventDispatcher: jest.fn(), +})); +jest.mock('../integrations/use-cases/get-integration-instance', () => ({ + GetIntegrationInstance: jest.fn(() => ({ + execute: jest.fn().mockResolvedValue({ id: 'i1' }), + })), +})); +jest.mock('../modules/module-factory', () => ({ + ModuleFactory: jest.fn(() => ({})), +})); +jest.mock('../integrations/repositories/process-repository-factory', () => ({ + createProcessRepository: jest.fn(() => ({ + findById: jest + .fn() + .mockResolvedValue({ integrationId: 'i1', userId: 'u1' }), + })), +})); +jest.mock( + '../integrations/repositories/integration-repository-factory', + () => ({ + createIntegrationRepository: jest.fn(() => ({ + findIntegrationById: jest + .fn() + .mockResolvedValue({ id: 'i1', userId: 'u1' }), + })), + }) +); +jest.mock('../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: jest.fn(() => ({})), +})); + +const { findNearestBackendPackageJson } = require('@friggframework/core/utils'); +const { ModuleFactory } = require('../modules/module-factory'); +const { + loadIntegrationForWebhook, + createQueueWorker, +} = require('./backend-utils'); + +function writeBackend(source) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'frigg-backend-utils-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"backend"}'); + fs.writeFileSync(path.join(dir, 'index.js'), source); + findNearestBackendPackageJson.mockReturnValue( + path.join(dir, 'package.json') + ); + return dir; +} + +describe('loadIntegrationForWebhook and the entity registry (ADR-059)', () => { + const dirs = []; + afterEach(() => { + for (const dir of dirs.splice(0)) + fs.rmSync(dir, { recursive: true, force: true }); + jest.clearAllMocks(); + }); + + const factoryDefinitions = () => + ModuleFactory.mock.calls[0][0].moduleDefinitions; + + it('builds the module factory from the registry, registry-only types included', async () => { + dirs.push( + writeBackend(` + class Sync { static Definition = { name: 'sync', modules: { crm: { definition: { moduleName: 'hubspot' } } } }; } + module.exports = { Definition: { + integrations: [Sync], + entities: { xero: { definition: { moduleName: 'xero' } } }, + } };`) + ); + + await loadIntegrationForWebhook('i1'); + + expect( + factoryDefinitions() + .map((d) => d.moduleName) + .sort() + ).toEqual(['hubspot', 'xero']); + }); + + it('an app with no entities key gets exactly the integration-bound definitions', async () => { + dirs.push( + writeBackend(` + class Sync { static Definition = { name: 'sync', modules: { crm: { definition: { moduleName: 'hubspot' } } } }; } + module.exports = { Definition: { integrations: [Sync] } };`) + ); + + await loadIntegrationForWebhook('i1'); + + expect(factoryDefinitions().map((d) => d.moduleName)).toEqual([ + 'hubspot', + ]); + }); + + describe('queue worker hydration by processId', () => { + class Sync { + static Definition = { + name: 'sync', + modules: { crm: { definition: { moduleName: 'hubspot' } } }, + }; + } + const run = async (options) => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + const Worker = createQueueWorker(Sync, options); + await new Worker() + ._run({ event: 'E', data: { processId: 'p1' } }, {}) + .catch(() => {}); + console.log.mockRestore(); + console.error.mockRestore(); + }; + + it('uses the module definitions the entry point passes (the app registry)', async () => { + const moduleDefinitions = [ + { moduleName: 'xero' }, + { moduleName: 'hubspot' }, + ]; + + await run({ moduleDefinitions }); + + expect(factoryDefinitions()).toBe(moduleDefinitions); + }); + + it('without any, falls back to the integration’s own modules', async () => { + await run(undefined); + + expect(factoryDefinitions().map((d) => d.moduleName)).toEqual([ + 'hubspot', + ]); + }); + }); +}); diff --git a/packages/core/handlers/backend-utils.js b/packages/core/handlers/backend-utils.js index 1ee6fee44..60807585e 100644 --- a/packages/core/handlers/backend-utils.js +++ b/packages/core/handlers/backend-utils.js @@ -18,8 +18,9 @@ const { createModuleRepository, } = require('../modules/repositories/module-repository-factory'); const { - getModulesDefinitionFromIntegrationClasses, -} = require('../integrations/utils/map-integration-dto'); + getEntityTypeRegistry, + getEntityTypeDefinitions, +} = require('../modules/entity-type-registry'); const { processNotFound } = require('../integrations/use-cases/process-errors'); const loadRouterFromObject = (IntegrationClass, routerObject) => { @@ -61,30 +62,18 @@ const initializeRepositories = () => { return { processRepository, integrationRepository, moduleRepository }; }; -const createModuleFactoryWithDefinitions = ( - moduleRepository, - integrationClasses -) => { - const moduleDefinitions = - getModulesDefinitionFromIntegrationClasses(integrationClasses); - - return new ModuleFactory({ - moduleRepository, - moduleDefinitions, - }); -}; - const loadIntegrationForWebhook = async (integrationId) => { const { loadAppDefinition } = require('./app-definition-loader'); - const { integrations: integrationClasses } = loadAppDefinition(); + const { integrations: integrationClasses, moduleDefinitions } = + loadAppDefinition(); const { integrationRepository, moduleRepository } = initializeRepositories(); - const moduleFactory = createModuleFactoryWithDefinitions( + const moduleFactory = new ModuleFactory({ moduleRepository, - integrationClasses - ); + moduleDefinitions, + }); const getIntegrationInstance = new GetIntegrationInstance({ integrationRepository, @@ -129,13 +118,23 @@ const integrationExists = async (integrationId) => { } }; -const loadIntegrationForProcess = async (processId, integrationClass) => { +const loadIntegrationForProcess = async ( + processId, + integrationClass, + moduleDefinitions +) => { const { processRepository, integrationRepository, moduleRepository } = initializeRepositories(); - const moduleFactory = createModuleFactoryWithDefinitions(moduleRepository, [ - integrationClass, - ]); + const moduleFactory = new ModuleFactory({ + moduleRepository, + moduleDefinitions: + moduleDefinitions ?? + // No app definition handed in: this integration's own modules only. + getEntityTypeDefinitions( + getEntityTypeRegistry({ integrations: [integrationClass] }) + ), + }); const getIntegrationInstance = new GetIntegrationInstance({ integrationRepository, @@ -161,7 +160,13 @@ const loadIntegrationForProcess = async (processId, integrationClass) => { return instance; }; -const createQueueWorker = (integrationClass) => { +/** + * @param {Function} integrationClass + * @param {{moduleDefinitions?: Array}} [options] - the app's offered + * module definitions (`loadAppDefinition().moduleDefinitions`). When omitted + * the worker hydrates with this integration's own modules only. + */ +const createQueueWorker = (integrationClass, { moduleDefinitions } = {}) => { const integrationName = integrationClass.Definition.name; class QueueWorker extends Worker { @@ -183,7 +188,8 @@ const createQueueWorker = (integrationClass) => { console.log(`[QueueWorker] hydrating by processId`, logCtx); integrationInstance = await loadIntegrationForProcess( params.data.processId, - integrationClass + integrationClass, + moduleDefinitions ); console.log(`[QueueWorker] hydrated`, { ...logCtx, diff --git a/packages/core/handlers/backend-utils.test.js b/packages/core/handlers/backend-utils.test.js index 7c2c0dc57..6adc7efd7 100644 --- a/packages/core/handlers/backend-utils.test.js +++ b/packages/core/handlers/backend-utils.test.js @@ -24,11 +24,8 @@ jest.mock( jest.mock('../modules/repositories/module-repository-factory', () => ({ createModuleRepository: jest.fn(), })); -jest.mock('../integrations/utils/map-integration-dto', () => ({ - getModulesDefinitionFromIntegrationClasses: jest.fn(() => []), -})); jest.mock('./app-definition-loader', () => ({ - loadAppDefinition: jest.fn(() => ({ integrations: [] })), + loadAppDefinition: jest.fn(() => ({ integrations: [], moduleDefinitions: [] })), })); const { integrationExists, createQueueWorker } = require('./backend-utils'); diff --git a/packages/core/handlers/routers/auth.test.js b/packages/core/handlers/routers/auth.test.js index 1b77ab3d5..57af7bb5b 100644 --- a/packages/core/handlers/routers/auth.test.js +++ b/packages/core/handlers/routers/auth.test.js @@ -1,4 +1,9 @@ -const mockAppDefinition = { integrations: [], userConfig: {}, managementApi: {} }; +const mockAppDefinition = { + integrations: [], + userConfig: {}, + managementApi: {}, + moduleDefinitions: [], +}; jest.mock('../app-definition-loader', () => ({ loadAppDefinition: () => mockAppDefinition, diff --git a/packages/core/handlers/routers/health.entity-registry.test.js b/packages/core/handlers/routers/health.entity-registry.test.js new file mode 100644 index 000000000..95a427bd5 --- /dev/null +++ b/packages/core/handlers/routers/health.entity-registry.test.js @@ -0,0 +1,145 @@ +/** + * Health sees entity types registered on the app (ADR-059), not only the ones + * an integration binds. Drives the real registry; only IO is faked. + */ +process.env.HEALTH_API_KEY = 'test-api-key'; + +jest.mock('../../database/config', () => ({ + DB_TYPE: 'mongodb', + getDatabaseType: jest.fn(() => 'mongodb'), + PRISMA_LOG_LEVEL: 'error,warn', +})); +jest.mock('../../database/prisma', () => ({ + prisma: {}, + connectPrisma: jest.fn(), + disconnectPrisma: jest.fn(), +})); +jest.mock('./../app-handler-helpers', () => ({ + createApp: jest.fn(), + createAppHandler: jest.fn(() => ({})), +})); +jest.mock( + '../../database/repositories/health-check-repository-factory', + () => ({ + createHealthCheckRepository: jest.fn(() => ({ + getDatabaseConnectionState: jest.fn().mockResolvedValue({ + readyState: 1, + stateName: 'connected', + isConnected: true, + }), + pingDatabase: jest.fn().mockResolvedValue(1), + })), + }) +); +jest.mock('../../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: jest.fn(() => ({})), +})); +jest.mock('../../modules/module-factory', () => ({ + ModuleFactory: jest.fn().mockImplementation(({ moduleDefinitions }) => ({ + moduleDefinitions, + })), +})); + +const google = { moduleName: 'google' }; +const hubspot = { moduleName: 'hubspot' }; +const integration = (name, definition) => ({ + Definition: { name, modules: { crm: { definition } } }, +}); + +const { + getEntityTypeRegistry, + getEntityTypeDefinitions, +} = require('../../modules/entity-type-registry'); + +// What loadAppDefinition() hands back: the definition plus its registry. +const loaded = (appDefinition) => { + const entityTypeRegistry = getEntityTypeRegistry(appDefinition); + return { + ...appDefinition, + entityTypeRegistry, + moduleDefinitions: getEntityTypeDefinitions(entityTypeRegistry), + }; +}; + +const loadHealthRouter = (appDefinition) => { + jest.resetModules(); + jest.doMock('./../app-definition-loader', () => ({ + loadAppDefinition: jest.fn(() => loaded(appDefinition)), + })); + return require('./health').router; +}; + +const callRoute = async (router, path) => { + const handler = router.stack.find( + (layer) => layer.route && layer.route.path === path + ).route.stack[0].handle; + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + await handler( + { path, headers: { 'x-frigg-health-api-key': 'test-api-key' } }, + res + ); + return res; +}; + +describe('health: entity registry (ADR-059)', () => { + let logSpy; + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => logSpy.mockRestore()); + + it('lists a registry-only module as an available module and is ready with no integration', async () => { + const router = loadHealthRouter({ + integrations: [], + entities: { google: { definition: google } }, + }); + + const res = await callRoute(router, '/health/ready'); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + ready: true, + checks: expect.objectContaining({ modules: true }), + }) + ); + }); + + it('reports registry and inferred modules together', async () => { + const router = loadHealthRouter({ + integrations: [integration('sync', hubspot)], + entities: { google: { definition: google } }, + }); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const res = await callRoute(router, '/health/detailed'); + errorSpy.mockRestore(); + + const { integrations } = res.json.mock.calls[0][0].checks; + expect(integrations.modules.available.sort()).toEqual([ + 'google', + 'hubspot', + ]); + expect(integrations.integrations.available).toEqual(['sync']); + }); + + it('an app with no entities key reports exactly the integration-bound modules', async () => { + const router = loadHealthRouter({ + integrations: [integration('sync', hubspot)], + }); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const res = await callRoute(router, '/health/detailed'); + errorSpy.mockRestore(); + + expect( + res.json.mock.calls[0][0].checks.integrations.modules.available + ).toEqual(['hubspot']); + }); +}); diff --git a/packages/core/handlers/routers/health.js b/packages/core/handlers/routers/health.js index 684581e76..26b7f6fb5 100644 --- a/packages/core/handlers/routers/health.js +++ b/packages/core/handlers/routers/health.js @@ -2,9 +2,6 @@ const { Router } = require('express'); const { createAppHandler } = require('./../app-handler-helpers'); const { loadAppDefinition } = require('./../app-definition-loader'); const { ModuleFactory } = require('../../modules/module-factory'); -const { - getModulesDefinitionFromIntegrationClasses, -} = require('../../integrations/utils/map-integration-dto'); const { createModuleRepository, } = require('../../modules/repositories/module-repository-factory'); @@ -45,7 +42,7 @@ try { integrationClasses = appDef.integrations || []; const moduleRepository = createModuleRepository(); - const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrationClasses); + const { moduleDefinitions } = appDef; moduleFactory = new ModuleFactory({ moduleRepository, diff --git a/packages/core/handlers/routers/health.test.js b/packages/core/handlers/routers/health.test.js index 85dac663b..6bc78b25f 100644 --- a/packages/core/handlers/routers/health.test.js +++ b/packages/core/handlers/routers/health.test.js @@ -50,16 +50,13 @@ jest.mock('../../database/repositories/health-check-repository-factory', () => ( jest.mock('./../app-definition-loader', () => ({ loadAppDefinition: jest.fn(() => ({ integrations: [{ Definition: { name: 'test-integration' } }], + moduleDefinitions: [ + { moduleName: 'test-module' }, + { moduleName: 'another-module' }, + ], })), })); -jest.mock('../../integrations/utils/map-integration-dto', () => ({ - getModulesDefinitionFromIntegrationClasses: jest.fn(() => [ - { moduleName: 'test-module' }, - { moduleName: 'another-module' }, - ]), -})); - jest.mock('../../modules/repositories/module-repository-factory', () => ({ createModuleRepository: jest.fn(() => ({})), })); diff --git a/packages/core/handlers/workers/integration-defined-workers.js b/packages/core/handlers/workers/integration-defined-workers.js index cb88ac0b6..77685e83a 100644 --- a/packages/core/handlers/workers/integration-defined-workers.js +++ b/packages/core/handlers/workers/integration-defined-workers.js @@ -10,10 +10,13 @@ const { createQueueWorker } = require('../backend-utils'); // const { getExtensionWorkers } = require('../../integrations/extension'); const handlers = {}; -const { integrations: integrationClasses } = loadAppDefinition(); +const { integrations: integrationClasses, moduleDefinitions } = + loadAppDefinition(); integrationClasses.forEach((IntegrationClass) => { - const defaultQueueWorker = createQueueWorker(IntegrationClass); + const defaultQueueWorker = createQueueWorker(IntegrationClass, { + moduleDefinitions, + }); handlers[`${IntegrationClass.Definition.name}`] = { queueWorker: createHandler({ diff --git a/packages/core/handlers/workers/integration-defined-workers.registry.test.js b/packages/core/handlers/workers/integration-defined-workers.registry.test.js new file mode 100644 index 000000000..860ec3444 --- /dev/null +++ b/packages/core/handlers/workers/integration-defined-workers.registry.test.js @@ -0,0 +1,36 @@ +/** + * ADR-059: the worker entry point hands every queue worker the app registry's + * module definitions, so workers and HTTP handlers offer the same entity types. + */ +const mockModuleDefinitions = [ + { moduleName: 'hubspot' }, + { moduleName: 'xero' }, +]; +class mockSync { + static Definition = { name: 'sync' }; +} + +jest.mock('@friggframework/core', () => ({ + createHandler: jest.fn((options) => options), +})); +jest.mock('../app-definition-loader', () => ({ + loadAppDefinition: () => ({ + integrations: [mockSync], + moduleDefinitions: mockModuleDefinitions, + }), +})); +jest.mock('../backend-utils', () => ({ + createQueueWorker: jest.fn(() => class {}), +})); + +const { createQueueWorker } = require('../backend-utils'); +const { handlers } = require('./integration-defined-workers'); + +describe('integration-defined-workers and the entity registry (ADR-059)', () => { + it('creates each queue worker with the app moduleDefinitions', () => { + expect(Object.keys(handlers)).toEqual(['sync']); + expect(createQueueWorker).toHaveBeenCalledWith(mockSync, { + moduleDefinitions: mockModuleDefinitions, + }); + }); +}); diff --git a/packages/core/index.js b/packages/core/index.js index 30a91bbdd..75d3fbfcd 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -63,7 +63,6 @@ const { Options, createIntegrationRouter, checkRequiredParams, - getModulesDefinitionFromIntegrationClasses, LoadIntegrationContextUseCase, } = require('./integrations/index'); const { @@ -99,6 +98,8 @@ const { Requester, ModuleConstants, ModuleFactory, + getEntityTypeRegistry, + EntityRegistryError, } = require('./modules/index'); const application = require('./application'); const utils = require('./utils'); @@ -150,7 +151,6 @@ module.exports = { Options, checkRequiredParams, createIntegrationRouter, - getModulesDefinitionFromIntegrationClasses, LoadIntegrationContextUseCase, CreateProcess, UpdateProcessState, @@ -206,6 +206,8 @@ module.exports = { Requester, ModuleConstants, ModuleFactory, + getEntityTypeRegistry, + EntityRegistryError, // queues QueuerUtil, diff --git a/packages/core/integrations/index.js b/packages/core/integrations/index.js index c2d432741..7c7aa140f 100644 --- a/packages/core/integrations/index.js +++ b/packages/core/integrations/index.js @@ -4,9 +4,6 @@ const { createIntegrationRouter, checkRequiredParams, } = require('./integration-router'); -const { - getModulesDefinitionFromIntegrationClasses, -} = require('./utils/map-integration-dto'); const { LoadIntegrationContextUseCase, } = require('./use-cases/load-integration-context'); @@ -21,7 +18,6 @@ module.exports = { Options, createIntegrationRouter, checkRequiredParams, - getModulesDefinitionFromIntegrationClasses, LoadIntegrationContextUseCase, validateExtensionBinding, getExtensionRoutes, diff --git a/packages/core/integrations/integration-router.js b/packages/core/integrations/integration-router.js index 2a19342c4..3337e6754 100644 --- a/packages/core/integrations/integration-router.js +++ b/packages/core/integrations/integration-router.js @@ -31,9 +31,6 @@ const { GetIntegrationInstance, } = require('./use-cases/get-integration-instance'); const { UpdateIntegration } = require('./use-cases/update-integration'); -const { - getModulesDefinitionFromIntegrationClasses, -} = require('./utils/map-integration-dto'); const { GetModuleInstanceFromType, } = require('../modules/use-cases/get-module-instance-from-type'); @@ -74,8 +71,13 @@ const { const log = getLogger('frigg.integrations'); function createIntegrationRouter() { - const { integrations: integrationClasses, userConfig } = - loadAppDefinition(); + // moduleDefinitions is the app registry's definitions (ADR-059), so + // /api/authorize and friends accept a type no integration binds. + const { + integrations: integrationClasses, + userConfig, + moduleDefinitions, + } = loadAppDefinition(); const moduleRepository = createModuleRepository(); const integrationRepository = createIntegrationRepository(); const credentialRepository = createCredentialRepository(); @@ -108,8 +110,7 @@ function createIntegrationRouter() { const moduleFactory = new ModuleFactory({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const deleteIntegrationForUser = new DeleteIntegrationForUser({ integrationRepository, @@ -136,8 +137,7 @@ function createIntegrationRouter() { const getEntitiesForUser = new GetEntitiesForUser({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const getIntegrationInstance = new GetIntegrationInstance({ @@ -153,37 +153,31 @@ function createIntegrationRouter() { }); const getModuleInstanceFromType = new GetModuleInstanceFromType({ - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const getEntityOptionsByType = new GetEntityOptionsByType({ - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const testModuleAuth = new TestModuleAuth({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const getModule = new GetModule({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const getEntityOptionsById = new GetEntityOptionsById({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const refreshEntityOptions = new RefreshEntityOptions({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const getPossibleIntegrations = new GetPossibleIntegrations({ @@ -194,8 +188,7 @@ function createIntegrationRouter() { moduleRepository, credentialRepository, integrationRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const router = express(); diff --git a/packages/core/integrations/tests/integration-router-authorize-logs.test.js b/packages/core/integrations/tests/integration-router-authorize-logs.test.js index cf50da1f0..f71d3c5a5 100644 --- a/packages/core/integrations/tests/integration-router-authorize-logs.test.js +++ b/packages/core/integrations/tests/integration-router-authorize-logs.test.js @@ -1,5 +1,5 @@ jest.mock('../../handlers/app-definition-loader', () => ({ - loadAppDefinition: () => ({ integrations: [], userConfig: {} }), + loadAppDefinition: () => ({ integrations: [], userConfig: {}, moduleDefinitions: [] }), })); jest.mock('../repositories/integration-repository-factory', () => ({ createIntegrationRepository: () => ({}), diff --git a/packages/core/integrations/tests/integration-router-entity-registry.test.js b/packages/core/integrations/tests/integration-router-entity-registry.test.js new file mode 100644 index 000000000..566d30765 --- /dev/null +++ b/packages/core/integrations/tests/integration-router-entity-registry.test.js @@ -0,0 +1,148 @@ +/** + * ADR-059 at the v1 router: POST /api/authorize accepts an entity type that is + * registered in appDefinition.entities and bound by no integration. + */ +const mockAppDefinition = { current: null }; +jest.mock('../../handlers/app-definition-loader', () => ({ + loadAppDefinition: () => mockAppDefinition.current, +})); +jest.mock('../repositories/integration-repository-factory', () => ({ + createIntegrationRepository: () => ({}), +})); +jest.mock( + '../../credential/repositories/credential-repository-factory', + () => ({ + createCredentialRepository: () => ({}), + }) +); +jest.mock('../../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: () => ({}), +})); +jest.mock('../../user/repositories/user-repository-factory', () => ({ + createUserRepository: () => ({}), +})); + +const { createIntegrationRouter } = require('../integration-router'); +const { createApp } = require('../../handlers/app-handler-helpers'); +const { AuthenticateUser } = require('../../user/use-cases/authenticate-user'); +const { + ProcessAuthorizationCallback, +} = require('../../modules/use-cases/process-authorization-callback'); + +const { + getEntityTypeRegistry, + getEntityTypeDefinitions, +} = require('../../modules/entity-type-registry'); + +// What loadAppDefinition() hands back: the definition plus its registry. +const loaded = (appDefinition) => ({ + ...appDefinition, + moduleDefinitions: getEntityTypeDefinitions( + getEntityTypeRegistry(appDefinition) + ), +}); + +const acme = { moduleName: 'acme' }; +const hubspot = { moduleName: 'hubspot' }; + +async function postAuthorize(body) { + const app = createApp((a) => a.use(createIntegrationRouter())); + const server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); + }); + try { + const res = await fetch( + `http://127.0.0.1:${server.address().port}/api/authorize`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + } + ); + return { status: res.status, text: await res.text() }; + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +describe('POST /api/authorize and the entity registry (ADR-059)', () => { + let seenDefinitions; + + beforeEach(() => { + seenDefinitions = null; + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(AuthenticateUser.prototype, 'execute').mockResolvedValue({ + getId: () => 'user-1', + }); + // Replace only what runs after the lookup; keep the wiring under test. + jest.spyOn( + ProcessAuthorizationCallback.prototype, + 'execute' + ).mockImplementation(async function (_userId, entityType) { + seenDefinitions = this.moduleDefinitions; + if ( + !this.moduleDefinitions.some( + (def) => def.moduleName === entityType + ) + ) { + throw new Error( + `Module definition not found for entity type: ${entityType}` + ); + } + return { credential_id: 'cred-1', entity_id: 'ent-1' }; + }); + }); + afterEach(() => jest.restoreAllMocks()); + + it('accepts a registry-only type', async () => { + mockAppDefinition.current = loaded({ + integrations: [ + { + Definition: { + name: 'sync', + modules: { crm: { definition: hubspot } }, + }, + }, + ], + entities: { acme: { definition: acme } }, + userConfig: {}, + }); + + const res = await postAuthorize({ + entityType: 'acme', + data: { code: 'x' }, + }); + + expect(res.status).toBe(200); + expect(JSON.parse(res.text)).toEqual({ + credential_id: 'cred-1', + entity_id: 'ent-1', + }); + expect(seenDefinitions).toEqual( + expect.arrayContaining([acme, hubspot]) + ); + }); + + it('does not accept the same type when the app registers nothing', async () => { + mockAppDefinition.current = loaded({ + integrations: [ + { + Definition: { + name: 'sync', + modules: { crm: { definition: hubspot } }, + }, + }, + ], + userConfig: {}, + }); + + const res = await postAuthorize({ + entityType: 'acme', + data: { code: 'x' }, + }); + + expect(res.status).toBe(500); + expect(seenDefinitions).toEqual([hubspot]); + }); +}); diff --git a/packages/core/integrations/utils/map-integration-dto.js b/packages/core/integrations/utils/map-integration-dto.js index 603ba7249..bcc51d7ac 100644 --- a/packages/core/integrations/utils/map-integration-dto.js +++ b/packages/core/integrations/utils/map-integration-dto.js @@ -20,18 +20,4 @@ function mapIntegrationClassToIntegrationDTO(integration) { } -const getModulesDefinitionFromIntegrationClasses = (integrationClasses) => { - return [ - ...new Set( - integrationClasses - .map((integration) => - Object.values(integration.Definition.modules).map( - (module) => module.definition - ) - ) - .flat() - ), - ]; -}; - -module.exports = { mapIntegrationClassToIntegrationDTO, getModulesDefinitionFromIntegrationClasses }; \ No newline at end of file +module.exports = { mapIntegrationClassToIntegrationDTO }; diff --git a/packages/core/management-api/contract.test.js b/packages/core/management-api/contract.test.js index d557b5f3e..7d1f4b9b4 100644 --- a/packages/core/management-api/contract.test.js +++ b/packages/core/management-api/contract.test.js @@ -4,7 +4,7 @@ * packages/devtools/infrastructure/__tests__/management-api-gateway-routes.test.js. */ jest.mock('../handlers/app-definition-loader', () => ({ - loadAppDefinition: () => ({ integrations: [], userConfig: {} }), + loadAppDefinition: () => ({ integrations: [], userConfig: {}, moduleDefinitions: [] }), })); jest.mock('../integrations/repositories/integration-repository-factory', () => ({ createIntegrationRepository: () => ({}), diff --git a/packages/core/management-api/openapi/components.js b/packages/core/management-api/openapi/components.js index 521a689fd..73a98542c 100644 --- a/packages/core/management-api/openapi/components.js +++ b/packages/core/management-api/openapi/components.js @@ -138,11 +138,17 @@ const SCHEMAS = { }, EntityType: { type: 'object', - required: ['type', 'name', 'authType', 'isMultiStep', 'stepCount'], + required: ['type', 'name', 'authType', 'isMultiStep', 'stepCount', 'usedBy'], properties: { type: { type: 'string' }, name: { type: 'string' }, description: { type: 'string' }, + usedBy: { + type: 'array', + items: { type: 'string' }, + description: + 'Names of the integrations that bind this entity type. Empty for a type registered only in appDefinition.entities.', + }, authType: { type: 'string', description: 'oauth2, oauth1, api-key, basic or custom' }, isMultiStep: { type: 'boolean' }, stepCount: { type: 'integer', minimum: 1 }, diff --git a/packages/core/management-api/openapi/v2-operations.js b/packages/core/management-api/openapi/v2-operations.js index e806c3f7c..7c4bec06a 100644 --- a/packages/core/management-api/openapi/v2-operations.js +++ b/packages/core/management-api/openapi/v2-operations.js @@ -112,10 +112,14 @@ const V2_OPERATIONS = { }, listEntityTypes: { summary: 'List the entity types (API modules) this app can connect', + description: + 'Every type registered in appDefinition.entities plus every module a declared integration binds. `usedBy` lists the integrations that bind each type; it is empty for a type registered without any integration.', responses: { 200: ok('Entity types', ref('ListEntityTypesResponse')), ...errors('Unauthorized') }, }, getEntityType: { summary: 'Describe an entity type', + description: + 'Includes `usedBy`, the integrations that bind the type (empty for a registry-only type).', responses: { 200: ok('Entity type', ref('EntityType')), ...errors('Unauthorized', 'NotFound') }, }, getEntityTypeRequirements: { diff --git a/packages/core/management-api/v2/dependencies.js b/packages/core/management-api/v2/dependencies.js index 1e83866f0..be68cc959 100644 --- a/packages/core/management-api/v2/dependencies.js +++ b/packages/core/management-api/v2/dependencies.js @@ -2,7 +2,16 @@ * Wires the repositories and use cases behind Management API v2. Kept apart * from the router so tests can hand the router doubles instead. */ -function buildV2Dependencies({ appDefinition, config }) { +/** + * @param {Object} params + * @param {Object} params.appDefinition - the loaded app definition. Its + * `entityTypeRegistry` (built once by loadAppDefinition) is used. + * @param {Object} params.config + * @param {Map} [params.entityTypeRegistry] - overrides the registry on the + * app definition. When neither exists (a raw definition in a test), it is + * built here from the definition. + */ +function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { const { createIntegrationRepository, } = require('../../integrations/repositories/integration-repository-factory'); @@ -17,8 +26,9 @@ function buildV2Dependencies({ appDefinition, config }) { } = require('../../user/repositories/user-repository-factory'); const { ModuleFactory } = require('../../modules/module-factory'); const { - getModulesDefinitionFromIntegrationClasses, - } = require('../../integrations/utils/map-integration-dto'); + getEntityTypeRegistry, + getEntityTypeDefinitions, + } = require('../../modules/entity-type-registry'); const { GetUserFromBearerToken } = require('../../user/use-cases/get-user-from-bearer-token'); const { GetUserFromXFriggHeaders } = require('../../user/use-cases/get-user-from-x-frigg-headers'); const { GetUserFromAdopterJwt } = require('../../user/use-cases/get-user-from-adopter-jwt'); @@ -70,8 +80,13 @@ function buildV2Dependencies({ appDefinition, config }) { const integrationClasses = appDefinition.integrations || []; const userConfig = appDefinition.userConfig || {}; - const moduleDefinitions = - getModulesDefinitionFromIntegrationClasses(integrationClasses); + // The registry holds entity types registered on the app as well as those + // integrations bind (ADR-059), so v2 authorize/entities accept both. + const registry = + entityTypeRegistry ?? + appDefinition.entityTypeRegistry ?? + getEntityTypeRegistry(appDefinition); + const moduleDefinitions = getEntityTypeDefinitions(registry); const moduleRepository = createModuleRepository(); const integrationRepository = createIntegrationRepository(); @@ -200,8 +215,8 @@ function buildV2Dependencies({ appDefinition, config }) { refreshEntityOptionsForUser: new entityOps.RefreshEntityOptionsForUser({ getEntityModuleForUser, }), - listEntityTypes: new ListEntityTypes({ moduleDefinitions }), - getEntityType: new GetEntityType({ moduleDefinitions }), + listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry }), + getEntityType: new GetEntityType({ entityTypeRegistry: registry }), getAuthorizationRequirements, // Credentials diff --git a/packages/core/management-api/v2/entity-registry.test.js b/packages/core/management-api/v2/entity-registry.test.js new file mode 100644 index 000000000..5511e91b0 --- /dev/null +++ b/packages/core/management-api/v2/entity-registry.test.js @@ -0,0 +1,199 @@ +/** + * ADR-059 at the v2 API: a type registered in appDefinition.entities is listed, + * described and authorizable with no integration binding it, and every + * description carries `usedBy`. Real registry and use cases; repositories and + * authentication are the only fakes. + */ +jest.mock( + '../../integrations/repositories/integration-repository-factory', + () => ({ + createIntegrationRepository: () => ({}), + }) +); +jest.mock( + '../../credential/repositories/credential-repository-factory', + () => ({ + createCredentialRepository: () => ({}), + }) +); +jest.mock('../../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: () => ({}), +})); +jest.mock('../../user/repositories/user-repository-factory', () => ({ + createUserRepository: () => ({}), +})); +jest.mock( + '../../modules/repositories/authorization-session-repository-factory', + () => ({ + createAuthorizationSessionRepository: () => ({}), + }) +); + +const { createApp } = require('../../handlers/app-handler-helpers'); +const { request } = require('../test-support/http-client'); +const { AuthenticateUser } = require('../../user/use-cases/authenticate-user'); +const { resolveManagementApiConfig } = require('../management-api-config'); +const { createManagementApiV2Router } = require('./create-v2-router'); +const { getEntityTypeRegistry } = require('../../modules/entity-type-registry'); + +const AUTH = { authorization: 'Bearer token' }; + +const moduleDefinition = (moduleName, extra = {}) => ({ + moduleName, + API: { requesterType: 'apiKey' }, + getAuthRequirementsForStep: async () => ({ + type: 'api-key', + data: { + jsonSchema: { + type: 'object', + properties: { key: { type: 'string' } }, + }, + }, + }), + ...extra, +}); + +const acme = moduleDefinition('acme'); // registry-only +const hubspot = moduleDefinition('hubspot'); // bound by an integration +const integration = (name, definition) => ({ + Definition: { name, modules: { crm: { definition } } }, +}); + +function appFor(appDefinition) { + // loadAppDefinition() builds the registry once and hands it back. + appDefinition = { + ...appDefinition, + entityTypeRegistry: getEntityTypeRegistry(appDefinition), + }; + const config = resolveManagementApiConfig(appDefinition.managementApi); + return createApp((app) => + app.use(createManagementApiV2Router({ appDefinition, config })) + ); +} + +describe('v2 entity types and the app-level registry (ADR-059)', () => { + const previousDbType = process.env.DB_TYPE; + beforeAll(() => { + process.env.DB_TYPE = 'postgresql'; + }); + afterAll(() => { + if (previousDbType === undefined) delete process.env.DB_TYPE; + else process.env.DB_TYPE = previousDbType; + }); + beforeEach(() => { + jest.spyOn(AuthenticateUser.prototype, 'execute').mockResolvedValue({ + getId: () => 'user-1', + }); + }); + afterEach(() => jest.restoreAllMocks()); + + const registryApp = () => + appFor({ + entities: { acme: { definition: acme, scope: 'global' } }, + integrations: [ + integration('sync', hubspot), + integration('report', hubspot), + ], + }); + + it('lists registry-only and integration-bound types, with usedBy', async () => { + const res = await request(registryApp(), { + path: '/api/v2/entities/types', + headers: AUTH, + }); + + expect(res.status).toBe(200); + expect(res.body.types.map((t) => [t.type, t.usedBy])).toEqual([ + ['acme', []], + ['hubspot', ['sync', 'report']], + ]); + }); + + it('describes a registry-only type: usedBy is empty', async () => { + const res = await request(registryApp(), { + path: '/api/v2/entities/types/acme', + headers: AUTH, + }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ type: 'acme', usedBy: [] }); + }); + + it('describes an integration-bound type with the integrations that bind it', async () => { + const res = await request(registryApp(), { + path: '/api/v2/entities/types/hubspot', + headers: AUTH, + }); + + expect(res.body).toMatchObject({ + type: 'hubspot', + usedBy: ['sync', 'report'], + }); + }); + + it('still answers 404 for a type that is neither registered nor bound', async () => { + const res = await request(registryApp(), { + path: '/api/v2/entities/types/nope', + headers: AUTH, + }); + + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('ENTITY_TYPE_NOT_FOUND'); + }); + + it('accepts a registry-only type at GET /api/v2/authorize and rejects an unknown one', async () => { + const app = registryApp(); + const ok = await request(app, { + path: '/api/v2/authorize?entityType=acme', + headers: AUTH, + }); + const unknown = await request(app, { + path: '/api/v2/authorize?entityType=nope', + headers: AUTH, + }); + + expect(ok.status).toBe(200); + expect(ok.body).toMatchObject({ step: 1, totalSteps: 1 }); + expect(unknown.status).toBe(404); + }); + + it('an app with no entities key lists only what its integrations bind', async () => { + const res = await request( + appFor({ integrations: [integration('sync', hubspot)] }), + { + path: '/api/v2/entities/types', + headers: AUTH, + } + ); + + expect(res.body.types.map((t) => [t.type, t.usedBy])).toEqual([ + ['hubspot', ['sync']], + ]); + }); + + it('uses the registry the loaded app definition carries instead of rebuilding one', async () => { + const definition = { + integrations: [integration('sync', hubspot)], + entityTypeRegistry: getEntityTypeRegistry({ + entities: { acme: { definition: acme } }, + integrations: [], + }), + }; + const config = resolveManagementApiConfig(undefined); + const app = createApp((a) => + a.use( + createManagementApiV2Router({ + appDefinition: definition, + config, + }) + ) + ); + + const res = await request(app, { + path: '/api/v2/entities/types', + headers: AUTH, + }); + + expect(res.body.types.map((t) => t.type)).toEqual(['acme']); + }); +}); diff --git a/packages/core/modules/entity-type-registry.js b/packages/core/modules/entity-type-registry.js new file mode 100644 index 000000000..13b9a7c30 --- /dev/null +++ b/packages/core/modules/entity-type-registry.js @@ -0,0 +1,311 @@ +/** + * App-level entity type registry (ADR-059). + * + * Pure: reads an app definition, does no I/O. It answers "which entity types + * (API modules) can this app connect?" from two sources: + * + * 1. `appDefinition.entities`, an object keyed by entity type name whose + * values are `{ definition, scope?, proxy?, display? }`. + * 2. Inference: every module bound by `appDefinition.integrations[*] + * .Definition.modules` (skipped when `entities.$infer === false`). + * + * `loadAppDefinition()` builds the registry once, at load, and returns it as + * `entityTypeRegistry` / `moduleDefinitions`; every consumer that holds a + * loaded app definition reads those instead of rebuilding. + */ + +const { BaseError } = require('../errors/base-error'); +const { getLogger } = require('../logs'); + +const log = getLogger('frigg.modules.entity_registry'); + +const INFER_KEY = '$infer'; +const UNNAMED = '(unnamed definition)'; + +/** + * Raised for an invalid `entities` registry. `code` is always + * `ENTITY_REGISTRY_INVALID`, so callers can tell it from other load errors. + */ +class EntityRegistryError extends BaseError { + constructor(message, options) { + super(message, options); + this.code = 'ENTITY_REGISTRY_INVALID'; + } +} + +const isPlainObject = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const appSource = () => ({ kind: 'app' }); +const integrationSource = (integrationClass) => ({ + kind: 'integration', + name: integrationClass?.Definition?.name || 'unnamed', +}); +const describeSource = (source) => + source.kind === 'app' ? 'app' : `integration:${source.name}`; + +const safeGetName = (definition) => { + if (typeof definition.getName !== 'function') return undefined; + try { + const name = definition.getName(); + return typeof name === 'string' && name ? name : undefined; + } catch { + return undefined; + } +}; + +const moduleNameOf = (definition) => + typeof definition?.moduleName === 'string' && definition.moduleName + ? definition.moduleName + : undefined; + +/** The name an entity type is known by: moduleName, else getName(). */ +function entityTypeNameOf(definition) { + if (!definition) return undefined; + return moduleNameOf(definition) ?? safeGetName(definition); +} + +/** Same object, or the same package name and version when both can be read. */ +function isSameDefinition(a, b) { + if (a === b) return true; + const packageId = (definition) => { + const { packageName, version } = definition || {}; + return typeof packageName === 'string' && + packageName && + typeof version === 'string' && + version + ? `${packageName}@${version}` + : null; + }; + const left = packageId(a); + return left !== null && left === packageId(b); +} + +function conflictError(name, existing, source) { + return new EntityRegistryError( + `Entity type '${name}' is supplied by two different definitions: ` + + `${existing.sources + .map(describeSource) + .join(', ')} and ${describeSource(source)}. ` + + 'They must be the same definition object (or the same package name and version).' + ); +} + +/** + * A registry key must equal the definition's moduleName and, when it has one, + * its getName(). Case matters. This leaves one name per entity type. + */ +function assertKeyMatchesDefinition(key, definition) { + const moduleName = moduleNameOf(definition); + const getName = safeGetName(definition); + const matches = + (moduleName !== undefined || getName !== undefined) && + (moduleName === undefined || key === moduleName) && + (getName === undefined || key === getName); + if (!matches) { + throw new EntityRegistryError( + `appDefinition.entities key '${key}' does not match its definition's name ` + + `(moduleName '${moduleName ?? '(none)'}', getName() '${ + getName ?? '(none)' + }'). ` + + 'The key must equal both, so each entity type has one name.' + ); + } +} + +const settingsOf = (entry) => { + const settings = {}; + for (const field of ['scope', 'proxy', 'display']) { + if (entry[field] !== undefined) settings[field] = entry[field]; + } + return settings; +}; + +/** + * `entities` absent keeps today's behaviour: inference on, and a name clash + * between integrations is not fatal. `entities` present (even `{}`) opts in to + * the strict rules. + */ +function readEntities(appDefinition) { + const entities = appDefinition?.entities; + if (entities === undefined || entities === null) { + return { strict: false, infer: true, entries: [] }; + } + if (!isPlainObject(entities)) { + throw new EntityRegistryError( + 'appDefinition.entities must be an object keyed by entity type name' + ); + } + const { [INFER_KEY]: inferFlag, ...rest } = entities; + if (inferFlag !== undefined && typeof inferFlag !== 'boolean') { + throw new EntityRegistryError( + `appDefinition.entities.${INFER_KEY} must be a boolean` + ); + } + return { + strict: true, + infer: inferFlag !== false, + entries: Object.entries(rest), + }; +} + +function registerAppEntries(registry, entries) { + for (const [key, entry] of entries) { + if ( + !isPlainObject(entry) || + !entry.definition || + typeof entry.definition !== 'object' + ) { + throw new EntityRegistryError( + `appDefinition.entities.${key} must be an object with a 'definition'` + ); + } + assertKeyMatchesDefinition(key, entry.definition); + registry.set(key, { + definition: entry.definition, + sources: [appSource()], + settings: settingsOf(entry), + }); + } +} + +function* boundDefinitions(integrationClass) { + const modules = integrationClass?.Definition?.modules; + if (!isPlainObject(modules)) return; + for (const bound of Object.values(modules)) { + if (bound?.definition) yield bound.definition; + } +} + +const addSource = (entry, source) => { + const known = entry.sources.some( + (s) => s.kind === source.kind && s.name === source.name + ); + if (!known) entry.sources.push(source); +}; + +/** + * Folds one integration-bound definition into the registry. Returns the name + * it could not register (only possible when inference is off), else undefined. + */ +function mergeBoundDefinition(registry, definition, source, { strict, infer }) { + const name = entityTypeNameOf(definition); + + if (!name) { + // No moduleName / getName(): it cannot be keyed by name. Offering it + // as before, de-duplicated by identity, keeps apps that never named + // one working. It cannot be registered, so `$infer: false` rejects it. + if (!infer) return UNNAMED; + const known = [...registry.values()].find( + (entry) => entry.definition === definition + ); + if (known) addSource(known, source); + else { + registry.set(`${UNNAMED} #${registry.size}`, { + definition, + sources: [source], + settings: {}, + }); + } + return undefined; + } + + const existing = registry.get(name); + if (existing) { + if (isSameDefinition(existing.definition, definition)) { + addSource(existing, source); + } else if (strict) { + throw conflictError(name, existing, source); + } else { + // Pre-ADR-059 apps keep working: the first definition wins, as + // ModuleFactory would have picked it. + log.warn('Two different definitions share one entity type name', { + eventName: `${log.name}.duplicate_definition`, + entityType: name, + kept: existing.sources.map(describeSource).join(', '), + ignored: describeSource(source), + }); + } + return undefined; + } + + if (!infer) return name; + registry.set(name, { definition, sources: [source], settings: {} }); + return undefined; +} + +function assertAllRegistered(unregistered) { + if (unregistered.size === 0) return; + const detail = [...unregistered] + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([name, sources]) => `${name} (bound by ${[...sources].join(', ')})` + ); + throw new EntityRegistryError( + `appDefinition.entities.${INFER_KEY} is false, so every module an integration binds must be registered. Missing: ${detail.join( + '; ' + )}` + ); +} + +const freezeEntries = (registry) => { + for (const entry of registry.values()) { + entry.sources.forEach(Object.freeze); + Object.freeze(entry.sources); + Object.freeze(entry.settings); + Object.freeze(entry); + } +}; + +/** + * Builds the registry for an app definition. + * + * @param {object} [appDefinition] - `{ entities?, integrations? }` + * @returns {Map, settings: {scope?: string|object, proxy?: object, display?: object}}>} + * @throws {EntityRegistryError} on a key/name mismatch, two different + * definitions under one name (when `entities` is present), or, with + * `$infer: false`, a bound module that is not registered. + */ +function getEntityTypeRegistry(appDefinition = {}) { + const mode = readEntities(appDefinition); + const registry = new Map(); + registerAppEntries(registry, mode.entries); + + const unregistered = new Map(); + for (const integrationClass of appDefinition?.integrations || []) { + const source = integrationSource(integrationClass); + for (const definition of boundDefinitions(integrationClass)) { + const missing = mergeBoundDefinition( + registry, + definition, + source, + mode + ); + if (missing !== undefined) { + if (!unregistered.has(missing)) + unregistered.set(missing, new Set()); + unregistered.get(missing).add(describeSource(source)); + } + } + } + assertAllRegistered(unregistered); + + freezeEntries(registry); + return registry; +} + +/** The definitions of a registry, in registry order. */ +const getEntityTypeDefinitions = (registry) => + [...registry.values()].map((entry) => entry.definition); + +/** Names of the integrations that bind an entity type; empty for registry-only. */ +const usedByOf = (entry) => + entry.sources.filter((s) => s.kind === 'integration').map((s) => s.name); + +module.exports = { + getEntityTypeRegistry, + getEntityTypeDefinitions, + usedByOf, + entityTypeNameOf, + EntityRegistryError, +}; diff --git a/packages/core/modules/entity-type-registry.test.js b/packages/core/modules/entity-type-registry.test.js new file mode 100644 index 000000000..734a8136c --- /dev/null +++ b/packages/core/modules/entity-type-registry.test.js @@ -0,0 +1,544 @@ +const { + getEntityTypeRegistry, + getEntityTypeDefinitions, + usedByOf, + EntityRegistryError, +} = require('./entity-type-registry'); +const { BaseError } = require('../errors/base-error'); +const { createMemorySink } = require('../logs'); + +const def = (moduleName, extra = {}) => ({ moduleName, ...extra }); +const integration = (name, modules) => ({ + Definition: { + name, + modules: Object.fromEntries( + Object.entries(modules).map(([key, definition]) => [ + key, + { definition }, + ]) + ), + }, +}); + +const APP = { kind: 'app' }; +const bound = (name) => ({ kind: 'integration', name }); + +const hubspot = def('hubspot'); +const google = def('google'); +const twilio = def('twilio'); + +describe('getEntityTypeRegistry', () => { + it('registry-only: a type no integration binds is offered, with no integration source', () => { + const registry = getEntityTypeRegistry({ + entities: { google: { definition: google } }, + integrations: [], + }); + expect([...registry.keys()]).toEqual(['google']); + expect(registry.get('google').definition).toBe(google); + expect(registry.get('google').sources).toEqual([{ kind: 'app' }]); + expect(usedByOf(registry.get('google'))).toEqual([]); + }); + + it('inferred-only: modules bound by integrations are offered with empty settings', () => { + const registry = getEntityTypeRegistry({ + integrations: [integration('sync', { crm: hubspot })], + }); + expect(registry.get('hubspot').definition).toBe(hubspot); + expect(registry.get('hubspot').sources).toEqual([ + { kind: 'integration', name: 'sync' }, + ]); + expect(registry.get('hubspot').settings).toEqual({}); + expect(usedByOf(registry.get('hubspot'))).toEqual(['sync']); + }); + + it('both: the same definition in the registry and an integration is one entry with both sources', () => { + const registry = getEntityTypeRegistry({ + entities: { hubspot: { definition: hubspot } }, + integrations: [integration('sync', { crm: hubspot })], + }); + expect(registry.size).toBe(1); + expect(registry.get('hubspot').sources).toEqual([APP, bound('sync')]); + }); + + it('two integrations sharing one definition object is not a conflict', () => { + const registry = getEntityTypeRegistry({ + integrations: [ + integration('a', { crm: hubspot }), + integration('b', { contacts: hubspot }), + ], + }); + expect(registry.size).toBe(1); + expect(registry.get('hubspot').sources).toEqual([ + bound('a'), + bound('b'), + ]); + }); + + it('one integration binding a definition twice lists the integration once', () => { + const registry = getEntityTypeRegistry({ + integrations: [integration('a', { one: hubspot, two: hubspot })], + }); + expect(registry.get('hubspot').sources).toEqual([ + { kind: 'integration', name: 'a' }, + ]); + }); + + it('two definitions with the same package name and version are the same definition', () => { + const first = def('hubspot', { + packageName: '@x/hubspot', + version: '1.2.0', + }); + const copy = def('hubspot', { + packageName: '@x/hubspot', + version: '1.2.0', + }); + const registry = getEntityTypeRegistry({ + entities: { hubspot: { definition: first } }, + integrations: [integration('a', { crm: copy })], + }); + expect(registry.get('hubspot').definition).toBe(first); + expect(registry.get('hubspot').sources).toEqual([APP, bound('a')]); + }); + + describe('conflicts', () => { + it('throws naming both sources when the registry and an integration disagree', () => { + expect(() => + getEntityTypeRegistry({ + entities: { hubspot: { definition: hubspot } }, + integrations: [ + integration('sync', { crm: def('hubspot') }), + ], + }) + ).toThrow(/'hubspot'.*app.*integration:sync/s); + }); + + it('throws naming both integrations when two integrations disagree', () => { + expect(() => + getEntityTypeRegistry({ + entities: {}, + integrations: [ + integration('a', { crm: def('hubspot') }), + integration('b', { crm: def('hubspot') }), + ], + }) + ).toThrow(/integration:a.*integration:b/s); + }); + + it('is an EntityRegistryError with a stable code', () => { + try { + getEntityTypeRegistry({ + entities: {}, + integrations: [ + integration('a', { crm: def('hubspot') }), + integration('b', { crm: def('hubspot') }), + ], + }); + throw new Error('expected a throw'); + } catch (error) { + expect(error).toBeInstanceOf(EntityRegistryError); + expect(error).toBeInstanceOf(BaseError); + expect(error.name).toBe('EntityRegistryError'); + expect(error.code).toBe('ENTITY_REGISTRY_INVALID'); + } + }); + + it('same package name but a different version conflicts', () => { + expect(() => + getEntityTypeRegistry({ + entities: {}, + integrations: [ + integration('a', { + crm: def('hubspot', { + packageName: '@x/hubspot', + version: '1.0.0', + }), + }), + integration('b', { + crm: def('hubspot', { + packageName: '@x/hubspot', + version: '2.0.0', + }), + }), + ], + }) + ).toThrow(EntityRegistryError); + }); + + it('a version alone, with no package name, is not enough to be the same', () => { + expect(() => + getEntityTypeRegistry({ + entities: {}, + integrations: [ + integration('a', { + crm: def('hubspot', { version: '1.0.0' }), + }), + integration('b', { + crm: def('hubspot', { version: '1.0.0' }), + }), + ], + }) + ).toThrow(EntityRegistryError); + }); + + it('package@version matching does not make two different definitions one: the first is kept', () => { + const first = def('hubspot', { + packageName: '@x/hubspot', + version: '1.0.0', + marker: 'first', + }); + const second = def('hubspot', { + packageName: '@x/hubspot', + version: '1.0.0', + marker: 'second', + }); + const registry = getEntityTypeRegistry({ + entities: { hubspot: { definition: first } }, + integrations: [integration('a', { crm: second })], + }); + expect(registry.get('hubspot').definition).toBe(first); + expect(registry.get('hubspot').definition.marker).toBe('first'); + }); + + describe('without an entities key (ADR: behaves as today)', () => { + it('two different definitions under one name do not throw: the first wins and a warning is logged', () => { + const sink = createMemorySink(); + const first = def('lenient-crm', { marker: 'first' }); + const second = def('lenient-crm', { marker: 'second' }); + + const registry = getEntityTypeRegistry({ + integrations: [ + integration('a', { crm: first }), + integration('b', { crm: second }), + ], + }); + + expect(registry.get('lenient-crm').definition).toBe(first); + expect(registry.get('lenient-crm').sources).toEqual([ + bound('a'), + ]); + expect(getEntityTypeDefinitions(registry)).toEqual([first]); + const warnings = sink.records.filter( + (r) => + r.eventName === + 'frigg.modules.entity_registry.duplicate_definition' + ); + expect(warnings).toEqual([ + expect.objectContaining({ + level: 'WARN', + entityType: 'lenient-crm', + kept: 'integration:a', + ignored: 'integration:b', + }), + ]); + }); + + it('the same clash is fatal as soon as entities is present, even empty', () => { + expect(() => + getEntityTypeRegistry({ + entities: {}, + integrations: [ + integration('a', { crm: def('strict-crm') }), + integration('b', { crm: def('strict-crm') }), + ], + }) + ).toThrow(EntityRegistryError); + }); + }); + + it('still applies with $infer: false', () => { + expect(() => + getEntityTypeRegistry({ + entities: { + $infer: false, + hubspot: { definition: hubspot }, + }, + integrations: [ + integration('sync', { crm: def('hubspot') }), + ], + }) + ).toThrow(/different definitions/); + }); + }); + + describe('key rule', () => { + it('throws when the key is not the definition name, naming key and name', () => { + expect(() => + getEntityTypeRegistry({ + entities: { crm: { definition: hubspot } }, + }) + ).toThrow(/key 'crm'.*'hubspot'/s); + }); + + it('accepts a key equal to both moduleName and getName()', () => { + const named = { moduleName: 'hubspot', getName: () => 'hubspot' }; + expect( + getEntityTypeRegistry({ + entities: { hubspot: { definition: named } }, + }).has('hubspot') + ).toBe(true); + }); + + it('rejects a key equal to getName() but not to moduleName', () => { + const named = { moduleName: 'hubspot', getName: () => 'hs' }; + expect(() => + getEntityTypeRegistry({ + entities: { hs: { definition: named } }, + }) + ).toThrow(/key 'hs'/); + }); + + it('rejects a key equal to moduleName but not to getName()', () => { + const named = { moduleName: 'hubspot', getName: () => 'hs' }; + expect(() => + getEntityTypeRegistry({ + entities: { hubspot: { definition: named } }, + }) + ).toThrow(/key 'hubspot'/); + }); + + it('cannot register one object under two keys (no double entry)', () => { + const named = { moduleName: 'hubspot', getName: () => 'hubspot' }; + expect(() => + getEntityTypeRegistry({ + entities: { + hubspot: { definition: named }, + hs: { definition: named }, + }, + }) + ).toThrow(/key 'hs'/); + }); + + it.each(['HubSpot', 'HUBSPOT', 'hubspot ', ' hubspot'])( + 'is case and whitespace sensitive: rejects key %p', + (key) => { + expect(() => + getEntityTypeRegistry({ + entities: { [key]: { definition: hubspot } }, + }) + ).toThrow(EntityRegistryError); + } + ); + + it.each(['HubSpot', 'HUBSPOT'])( + 'is case sensitive for a definition that only has getName(): rejects %p', + (key) => { + const named = { getName: () => 'hubspot' }; + expect(() => + getEntityTypeRegistry({ + entities: { [key]: { definition: named } }, + }) + ).toThrow(EntityRegistryError); + } + ); + + it('rejects a definition with neither moduleName nor getName()', () => { + expect(() => + getEntityTypeRegistry({ + entities: { anything: { definition: {} } }, + }) + ).toThrow(EntityRegistryError); + }); + + it('uses getName() when there is no moduleName', () => { + const named = { getName: () => 'hubspot' }; + expect( + getEntityTypeRegistry({ + entities: { hubspot: { definition: named } }, + }).has('hubspot') + ).toBe(true); + expect(() => + getEntityTypeRegistry({ + entities: { crm: { definition: named } }, + }) + ).toThrow(EntityRegistryError); + }); + + it('rejects an entry with no definition, naming the key', () => { + expect(() => + getEntityTypeRegistry({ + entities: { hubspot: { scope: 'global' } }, + }) + ).toThrow(/entities\.hubspot/); + }); + + it('rejects an entities value that is not an object', () => { + expect(() => + getEntityTypeRegistry({ entities: [hubspot] }) + ).toThrow(EntityRegistryError); + }); + }); + + describe('$infer', () => { + it('false: throws listing every missing name and the integration that binds it', () => { + expect(() => + getEntityTypeRegistry({ + entities: { + $infer: false, + hubspot: { definition: hubspot }, + }, + integrations: [ + integration('a', { crm: hubspot, chat: def('slack') }), + integration('b', { mail: google }), + ], + }) + ).toThrow( + /Missing: google \(bound by integration:b\); slack \(bound by integration:a\)/ + ); + }); + + it('false with everything registered passes and still records integration sources', () => { + const registry = getEntityTypeRegistry({ + entities: { + $infer: false, + hubspot: { definition: hubspot }, + google: { definition: google }, + }, + integrations: [integration('sync', { crm: hubspot })], + }); + expect([...registry.keys()].sort()).toEqual(['google', 'hubspot']); + expect(registry.get('hubspot').sources).toEqual([ + APP, + bound('sync'), + ]); + expect(registry.get('google').sources).toEqual([{ kind: 'app' }]); + }); + + it('false does not offer a module an integration binds but nobody registered (it throws instead)', () => { + expect(() => + getEntityTypeRegistry({ + entities: { $infer: false }, + integrations: [integration('a', { crm: hubspot })], + }) + ).toThrow(/hubspot/); + }); + + it('true or absent infers', () => { + for (const entities of [{ $infer: true }, {}, undefined]) { + const registry = getEntityTypeRegistry({ + entities, + integrations: [integration('a', { crm: hubspot })], + }); + expect(registry.has('hubspot')).toBe(true); + } + }); + + it('is not treated as an entity', () => { + const registry = getEntityTypeRegistry({ + entities: { $infer: false }, + }); + expect(registry.size).toBe(0); + }); + + it('must be a boolean', () => { + expect(() => + getEntityTypeRegistry({ entities: { $infer: 'no' } }) + ).toThrow(/\$infer must be a boolean/); + }); + }); + + describe('settings', () => { + it('copies scope, proxy and display from the registry entry only', () => { + const settings = { + scope: { + default: 'individual', + allow: ['individual', 'global'], + }, + proxy: { allow: 'module' }, + display: { label: 'HubSpot CRM' }, + }; + const registry = getEntityTypeRegistry({ + entities: { + hubspot: { + definition: hubspot, + ...settings, + ignored: true, + }, + }, + integrations: [integration('sync', { crm: hubspot })], + }); + expect(registry.get('hubspot').settings).toEqual(settings); + }); + + it('omits settings the entry does not set', () => { + const registry = getEntityTypeRegistry({ + entities: { twilio: { definition: twilio, scope: 'global' } }, + }); + expect(registry.get('twilio').settings).toEqual({ + scope: 'global', + }); + }); + }); + + describe('sources', () => { + it('lists app first, then each binding integration in declaration order', () => { + const registry = getEntityTypeRegistry({ + entities: { hubspot: { definition: hubspot } }, + integrations: [ + integration('z-last', { crm: hubspot }), + integration('a-first', { crm: hubspot }), + ], + }); + expect(registry.get('hubspot').sources).toEqual([ + APP, + bound('z-last'), + bound('a-first'), + ]); + }); + + it('returns frozen entries', () => { + const entry = getEntityTypeRegistry({ + entities: { hubspot: { definition: hubspot } }, + }).get('hubspot'); + expect(Object.isFrozen(entry)).toBe(true); + expect(Object.isFrozen(entry.sources)).toBe(true); + expect(Object.isFrozen(entry.settings)).toBe(true); + }); + }); + + it('is pure: does not mutate the app definition', () => { + const appDefinition = { + entities: { hubspot: { definition: hubspot, scope: 'global' } }, + integrations: [integration('a', { crm: hubspot })], + }; + const before = JSON.stringify(appDefinition); + getEntityTypeRegistry(appDefinition); + expect(JSON.stringify(appDefinition)).toBe(before); + }); + + it('tolerates an empty or missing app definition and integrations without modules', () => { + expect(getEntityTypeRegistry().size).toBe(0); + expect(getEntityTypeRegistry({}).size).toBe(0); + expect( + getEntityTypeRegistry({ + integrations: [{ Definition: { name: 'bare' } }], + }).size + ).toBe(0); + }); + + describe('a bound definition with no moduleName or getName()', () => { + const anonymous = { encryption: { credentialFields: ['token'] } }; + + it('is still offered, de-duplicated by identity, as before', () => { + const definitions = getEntityTypeDefinitions( + getEntityTypeRegistry({ + integrations: [ + integration('a', { one: anonymous }), + integration('b', { two: anonymous }), + integration('c', { three: { encryption: {} } }), + ], + }) + ); + expect(definitions).toHaveLength(2); + expect(definitions[0]).toBe(anonymous); + }); + + it('cannot be registered, so $infer: false rejects it', () => { + expect(() => + getEntityTypeRegistry({ + entities: { $infer: false }, + integrations: [integration('a', { one: anonymous })], + }) + ).toThrow(/\(unnamed definition\) \(bound by integration:a\)/); + }); + }); +}); diff --git a/packages/core/modules/index.js b/packages/core/modules/index.js index 913948027..c59ea26a7 100644 --- a/packages/core/modules/index.js +++ b/packages/core/modules/index.js @@ -4,6 +4,10 @@ const { OAuth2Requester } = require('./requester/oauth-2'); const { Requester } = require('./requester/requester'); const { ModuleConstants } = require('./ModuleConstants'); const { ModuleFactory } = require('./module-factory'); +const { + getEntityTypeRegistry, + EntityRegistryError, +} = require('./entity-type-registry'); module.exports = { ApiKeyRequester, @@ -12,4 +16,6 @@ module.exports = { Requester, ModuleConstants, ModuleFactory, + getEntityTypeRegistry, + EntityRegistryError, }; diff --git a/packages/core/modules/use-cases/entity-types.js b/packages/core/modules/use-cases/entity-types.js index 0dc118a0c..2175f06ec 100644 --- a/packages/core/modules/use-cases/entity-types.js +++ b/packages/core/modules/use-cases/entity-types.js @@ -1,5 +1,6 @@ const Boom = require('@hapi/boom'); const { ModuleConstants } = require('../ModuleConstants'); +const { usedByOf } = require('../entity-type-registry'); const AUTH_TYPE_BY_REQUESTER = { [ModuleConstants.authType.oauth2]: 'oauth2', @@ -22,16 +23,16 @@ function getAuthStepCount(definition) { * definition: display metadata, auth type and step count. Never credentials * or env values. */ -function describeEntityType(definition) { +function describeEntityType(definition, typeName = definition.moduleName) { const stepCount = getAuthStepCount(definition); const display = definition.display || {}; const description = { - type: definition.moduleName, + type: typeName, name: callIfFunction(definition, 'getDisplayName') ?? display.label ?? display.name ?? - definition.moduleName, + typeName, authType: callIfFunction(definition, 'getAuthType') ?? AUTH_TYPE_BY_REQUESTER[definition.API?.requesterType] ?? @@ -58,27 +59,50 @@ function findDefinition(moduleDefinitions, entityType) { return definition; } -/** Lists the entity types (API modules) this app can authorize. */ +const describeEntry = (name, entry) => ({ + // The registry key is the name the API uses, whatever the definition spells. + ...describeEntityType(entry.definition, name), + usedBy: usedByOf(entry), +}); + +function requireRegistry(entityTypeRegistry) { + if (!(entityTypeRegistry instanceof Map)) { + throw new TypeError('entityTypeRegistry is required (see getEntityTypeRegistry)'); + } + return entityTypeRegistry; +} + +/** + * Lists the entity types this app can authorize: registry entries plus every + * module an integration binds (ADR-059). `usedBy` names the integrations that + * bind each type; it is empty for a registry-only type. + */ class ListEntityTypes { - constructor({ moduleDefinitions }) { - this.moduleDefinitions = moduleDefinitions || []; + constructor({ entityTypeRegistry }) { + this.registry = requireRegistry(entityTypeRegistry); } async execute() { - return this.moduleDefinitions - .map(describeEntityType) + return [...this.registry] + .map(([name, entry]) => describeEntry(name, entry)) .sort((a, b) => a.name.localeCompare(b.name)); } } -/** Describes one entity type; 404 when the app does not configure it. */ +/** Describes one entity type, looked up by its registry key; 404 when absent. */ class GetEntityType { - constructor({ moduleDefinitions }) { - this.moduleDefinitions = moduleDefinitions || []; + constructor({ entityTypeRegistry }) { + this.registry = requireRegistry(entityTypeRegistry); } async execute(entityType) { - return describeEntityType(findDefinition(this.moduleDefinitions, entityType)); + const entry = this.registry.get(entityType); + if (!entry) { + throw Boom.notFound(`Entity type '${entityType}' not found`, { + code: 'ENTITY_TYPE_NOT_FOUND', + }); + } + return describeEntry(entityType, entry); } } diff --git a/packages/core/modules/use-cases/entity-types.test.js b/packages/core/modules/use-cases/entity-types.test.js index 2be2c1ba0..a21556871 100644 --- a/packages/core/modules/use-cases/entity-types.test.js +++ b/packages/core/modules/use-cases/entity-types.test.js @@ -18,6 +18,15 @@ const multiStepModule = { getCapabilities: () => ['contacts'], }; +const { getEntityTypeRegistry } = require('../entity-type-registry'); + +const registryOf = (...definitions) => + getEntityTypeRegistry({ + integrations: definitions.map((definition, i) => ({ + Definition: { name: `i${i}`, modules: { m: { definition } } }, + })), + }); + describe('entity types', () => { it('describes a module from its definition only', () => { expect(describeEntityType(oauthModule)).toEqual({ @@ -46,17 +55,104 @@ describe('entity types', () => { it('lists types sorted by name', async () => { const types = await new ListEntityTypes({ - moduleDefinitions: [oauthModule, multiStepModule], + entityTypeRegistry: registryOf(oauthModule, multiStepModule), }).execute(); expect(types.map((t) => t.type)).toEqual(['alpha', 'zeta']); }); it('answers 404 for an unknown type', async () => { await expect( - new GetEntityType({ moduleDefinitions: [oauthModule] }).execute('nope') + new GetEntityType({ + entityTypeRegistry: registryOf(oauthModule), + }).execute('nope') ).rejects.toMatchObject({ output: { statusCode: 404 }, data: { code: 'ENTITY_TYPE_NOT_FOUND' }, }); }); + describe('usedBy and the registry (ADR-059)', () => { + // Only getName(): found by registry key, never by moduleName. + const getNameOnly = { + getName: () => 'gamma', + API: { requesterType: 'oauth2' }, + }; + const registry = getEntityTypeRegistry({ + entities: { + zeta: { definition: oauthModule }, + gamma: { definition: getNameOnly }, + }, + integrations: [ + { + Definition: { + name: 'sync', + modules: { crm: { definition: multiStepModule } }, + }, + }, + ], + }); + + it('lists registry-only types with an empty usedBy and bound types with their integrations', async () => { + const types = await new ListEntityTypes({ + entityTypeRegistry: registry, + }).execute(); + + expect(types.map((t) => [t.type, t.usedBy])).toEqual([ + ['alpha', ['sync']], + ['gamma', []], + ['zeta', []], + ]); + }); + + it('describes one registry-only type', async () => { + const type = await new GetEntityType({ + entityTypeRegistry: registry, + }).execute('zeta'); + + expect(type).toMatchObject({ type: 'zeta', usedBy: [] }); + }); + + it('looks up by registry key, so every listed type can be described', async () => { + const listed = await new ListEntityTypes({ + entityTypeRegistry: registry, + }).execute(); + const get = new GetEntityType({ entityTypeRegistry: registry }); + + for (const { type } of listed) { + await expect(get.execute(type)).resolves.toMatchObject({ + type, + }); + } + }); + + it('is case sensitive: ZETA is not zeta', async () => { + await expect( + new GetEntityType({ entityTypeRegistry: registry }).execute( + 'ZETA' + ) + ).rejects.toMatchObject({ + data: { code: 'ENTITY_TYPE_NOT_FOUND' }, + }); + }); + + it('reports the registry key as type for a definition that only has getName()', async () => { + const named = { + getName: () => 'only-getname', + API: { requesterType: 'oauth2' }, + }; + const types = await new ListEntityTypes({ + entityTypeRegistry: registryOf(named), + }).execute(); + + expect(types.map((t) => t.type)).toEqual(['only-getname']); + }); + + it('requires a registry', () => { + expect( + () => new ListEntityTypes({ moduleDefinitions: [oauthModule] }) + ).toThrow(/entityTypeRegistry is required/); + expect(() => new GetEntityType({})).toThrow( + /entityTypeRegistry is required/ + ); + }); + }); }); diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 393facda7..125f8ed6c 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -12,3 +12,4 @@ /// /// /// +/// diff --git a/packages/core/types/modules/index.d.ts b/packages/core/types/modules/index.d.ts new file mode 100644 index 000000000..c675b801b --- /dev/null +++ b/packages/core/types/modules/index.d.ts @@ -0,0 +1,58 @@ +declare module "@friggframework/core" { + /** Where an entity type comes from: the app's `entities` map or an integration binding it. */ + export type EntityTypeSource = + | { kind: "app" } + | { kind: "integration"; name: string }; + + export type EntityScope = "individual" | "organization" | "global"; + + /** App-level settings carried from the `entities` entry; empty for an inferred-only type. */ + export interface EntityTypeSettings { + scope?: EntityScope | { default?: EntityScope; allow?: EntityScope[] }; + proxy?: Record; + display?: Record; + } + + export interface EntityTypeEntry { + /** The API module definition. */ + definition: any; + /** Frozen. `{ kind: 'app' }` first when registered, then each binding integration. */ + sources: readonly EntityTypeSource[]; + /** Frozen. */ + settings: Readonly; + } + + /** Entity type name (the definition's moduleName) to its entry. */ + export type EntityTypeRegistry = Map; + + /** One `appDefinition.entities` entry. The key must equal the definition's moduleName (and getName() when defined). */ + export interface EntityRegistration extends EntityTypeSettings { + definition: any; + } + + export interface AppDefinitionEntities { + /** false turns inference off: every module an integration binds must be registered. Default true. */ + $infer?: boolean; + [entityType: string]: EntityRegistration | boolean | undefined; + } + + export interface AppDefinition { + integrations: any[]; + /** App-level entity type registry (ADR-059). */ + entities?: AppDefinitionEntities; + [key: string]: unknown; + } + + /** + * Builds the registry from `entities` plus the modules integrations bind. + * Throws EntityRegistryError on an invalid registry. + */ + export function getEntityTypeRegistry( + appDefinition?: Pick + ): EntityTypeRegistry; + + /** Invalid `entities` registry. `code` is always `ENTITY_REGISTRY_INVALID`. */ + export class EntityRegistryError extends Error { + code: "ENTITY_REGISTRY_INVALID"; + } +} diff --git a/packages/schemas/schemas/api-entities.schema.json b/packages/schemas/schemas/api-entities.schema.json index d62d4e519..0caae9ac6 100644 --- a/packages/schemas/schemas/api-entities.schema.json +++ b/packages/schemas/schemas/api-entities.schema.json @@ -122,6 +122,11 @@ "type": "string", "description": "Description of the entity type" }, + "usedBy": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of the integrations that bind this entity type; empty for a type registered only in appDefinition.entities" + }, "authType": { "type": "string", "enum": ["oauth2", "form", "api-key", "basic"], diff --git a/packages/schemas/schemas/app-definition.schema.json b/packages/schemas/schemas/app-definition.schema.json index 6f295e23c..897b977f9 100644 --- a/packages/schemas/schemas/app-definition.schema.json +++ b/packages/schemas/schemas/app-definition.schema.json @@ -643,6 +643,56 @@ }, "additionalProperties": false }, + "entities": { + "type": "object", + "description": "App-level entity type registry (ADR-059). Keyed by entity type name; each key must equal its definition's moduleName / getName(). Registered types can be connected without any integration. Entity types bound by declared integrations are inferred and merged unless $infer is false.", + "properties": { + "$infer": { + "type": "boolean", + "default": true, + "description": "false turns inference off: every module an integration binds must then be registered here, or the app fails to load." + } + }, + "additionalProperties": { + "type": "object", + "description": "One entity type: its module definition plus app-level settings.", + "required": ["definition"], + "properties": { + "definition": { + "type": "object", + "description": "The API module definition (the object an integration binds as modules..definition)." + }, + "scope": { + "description": "Who owns entities of this type (ADR-056): one scope, or a default plus the scopes callers may choose.", + "oneOf": [ + { "type": "string", "enum": ["individual", "organization", "global"] }, + { + "type": "object", + "properties": { + "default": { "type": "string", "enum": ["individual", "organization", "global"] }, + "allow": { + "type": "array", + "items": { "type": "string", "enum": ["individual", "organization", "global"] }, + "minItems": 1, + "uniqueItems": true + } + }, + "additionalProperties": false + } + ] + }, + "proxy": { + "type": "object", + "description": "Per-type entity proxy allow-list override (ADR-052)." + }, + "display": { + "type": "object", + "description": "Label, icon and description shown by UIs and GET /api/v2/entity-types." + } + }, + "additionalProperties": false + } + }, "managementApi": { "type": "object", "description": "Management API settings (ADR-053 versioning, ADR-052 entity proxy).", diff --git a/packages/schemas/tests/app-definition-entities.test.js b/packages/schemas/tests/app-definition-entities.test.js new file mode 100644 index 000000000..f59bd5b1b --- /dev/null +++ b/packages/schemas/tests/app-definition-entities.test.js @@ -0,0 +1,121 @@ +const { validateAppDefinition } = require("../index"); + +const baseDefinition = { + name: "test-app", + provider: "aws", + integrations: [], +}; + +const validate = (entities) => + validateAppDefinition({ ...baseDefinition, entities }); + +const hubspot = { definition: { moduleName: "hubspot" } }; + +describe("app-definition schema: entities (ADR-059)", () => { + it("stays optional: an app with no entities key is valid", () => { + expect(validateAppDefinition(baseDefinition).valid).toBe(true); + }); + + it("accepts the ADR example", () => { + expect( + validate({ + google: { definition: { moduleName: "google" }, scope: "individual" }, + hubspot, + twilio: { definition: { moduleName: "twilio" }, scope: "global" }, + }).valid + ).toBe(true); + }); + + it("accepts $infer as a boolean, alongside entries", () => { + expect(validate({ $infer: false, hubspot }).valid).toBe(true); + expect(validate({ $infer: true }).valid).toBe(true); + }); + + it.each(["yes", 0, null, {}])( + "rejects a non-boolean $infer (%p)", + (value) => { + expect(validate({ $infer: value, hubspot }).valid).toBe(false); + } + ); + + it("requires a definition on every entry", () => { + expect(validate({ hubspot: { scope: "global" } }).valid).toBe(false); + expect(validate({ hubspot: {} }).valid).toBe(false); + }); + + it("rejects entries that are not objects", () => { + expect(validate({ hubspot: "hubspot" }).valid).toBe(false); + expect(validate({ hubspot: null }).valid).toBe(false); + }); + + it("rejects unknown keys on an entry", () => { + expect(validate({ hubspot: { ...hubspot, alias: "crm" } }).valid).toBe( + false + ); + }); + + it("rejects entities that is not an object", () => { + expect(validate([hubspot]).valid).toBe(false); + expect(validate("hubspot").valid).toBe(false); + }); + + describe("scope", () => { + it.each(["individual", "organization", "global"])( + 'accepts "%s"', + (scope) => { + expect(validate({ hubspot: { ...hubspot, scope } }).valid).toBe(true); + } + ); + + it("accepts { default, allow }", () => { + expect( + validate({ + hubspot: { + ...hubspot, + scope: { + default: "individual", + allow: ["individual", "organization"], + }, + }, + }).valid + ).toBe(true); + }); + + it.each([ + "team", + "", + { default: "team" }, + { allow: ["individual", "team"] }, + { allow: [] }, + { allow: ["global", "global"] }, + { default: "global", extra: true }, + 7, + ])("rejects %p", (scope) => { + expect(validate({ hubspot: { ...hubspot, scope } }).valid).toBe(false); + }); + }); + + it("accepts proxy and display objects and rejects other types", () => { + expect( + validate({ + hubspot: { + ...hubspot, + proxy: { allow: "module" }, + display: { label: "HubSpot", icon: "hubspot" }, + }, + }).valid + ).toBe(true); + expect(validate({ hubspot: { ...hubspot, proxy: "all" } }).valid).toBe( + false + ); + expect( + validate({ hubspot: { ...hubspot, display: "HubSpot" } }).valid + ).toBe(false); + }); + + it("keeps the top-level additionalProperties policy", () => { + expect( + validateAppDefinition({ ...baseDefinition, entitys: {} }).valid + ).toBe(false); + }); +}); From 41e6142a23af6cee066aa1c828899106ab6e67f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:58:35 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(core):=20entity=20scopes=20and=20owner?= =?UTF-8?q?ship=20by=20scope,=20organizationId=20on=20entities=20and=20int?= =?UTF-8?q?egrations=20(ADR-056=20=C2=A73.1-=C2=A73.4,=20=C2=A73.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scope-resolution / scope-config / scope-visibility / scope-policy (re-exported by entity-scope.js): scope resolution (request > binding > integration > entity type > app > default from user.primary), ownership on create, one visibility clause list used as the repository query and as the in-memory access check, legacy-row rules. - Entity and Integration gain a nullable organizationId (both Prisma schemas, one PostgreSQL migration). No scope column: the owner's user type says it. - AuthorizeEntityForUser (v1 and v2) owns by scope and pairs with the active organization. Lists, integration create (v1 and v2) and every entity load use the same rule; v1 integration create now checks entity ownership. - ResolveIntegrationOwnership refuses mixed-scope integrations with a 400 before anything is written; CreateIntegration validates before persisting; an organization-owned legacy integration is adopted on a repeated create. - The entity proxy fails closed for organization-scoped entities until proxy.scopes (F6). - ownership config validated at app-definition load; schemas, OpenAPI, types and docs/guides/entity-scopes.md updated. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- docs/guides/entity-scopes.md | 185 +++++ docs/guides/organization-memberships.md | 9 +- packages/core/README.md | 36 + .../commands/integration-commands.js | 4 +- packages/core/database/organization-where.js | 18 + .../core/handlers/app-definition-loader.js | 10 +- .../handlers/app-definition-loader.test.js | 43 ++ packages/core/index.js | 6 + .../core/integrations/integration-router.js | 114 ++- .../repositories/integration-not-found.js | 14 + .../integration-repository-documentdb.js | 37 +- .../integration-repository-interface.js | 18 +- .../integration-repository-mongo.js | 135 ++-- .../integration-repository-ownership.test.js | 138 ++++ .../integration-repository-postgres.js | 138 ++-- .../integration-repository-postgres.test.js | 1 + .../doubles/test-integration-repository.js | 13 +- .../integration-router-ownership.test.js | 194 +++++ .../get-integrations-for-user.test.js | 8 +- .../use-cases/integration-ownership.test.js | 501 +++++++++++++ .../use-cases/create-integration.js | 75 +- .../use-cases/get-integrations-for-user.js | 120 ++- packages/core/integrations/use-cases/index.js | 6 +- .../integration-operations-for-user.js | 59 +- .../integration-operations-for-user.test.js | 28 +- .../resolve-integration-ownership.js | 177 +++++ .../management-api/management-api-config.js | 3 + .../core/management-api/openapi/components.js | 39 + .../management-api/openapi/v2-operations.js | 11 +- .../v2/create-v2-router.test.js | 8 +- .../core/management-api/v2/dependencies.js | 45 +- packages/core/management-api/v2/dto.js | 17 + .../management-api/v2/entity-scopes.test.js | 445 +++++++++++ .../v2/handlers/authorization.js | 1 + .../management-api/v2/handlers/entities.js | 17 +- .../v2/handlers/integrations.js | 9 +- packages/core/management-api/v2/request.js | 33 +- .../core/management-api/v2/request.test.js | 24 + packages/core/modules/entity-scope.js | 15 + packages/core/modules/entity-scope.test.js | 698 ++++++++++++++++++ packages/core/modules/index.js | 8 + .../module-repository-ownership.test.js | 184 +++++ .../module-repository-documentdb.js | 42 +- .../module-repository-interface.js | 21 +- .../repositories/module-repository-mongo.js | 137 ++-- .../module-repository-postgres.js | 136 ++-- packages/core/modules/scope-config.js | 155 ++++ packages/core/modules/scope-policy.js | 80 ++ packages/core/modules/scope-resolution.js | 277 +++++++ packages/core/modules/scope-visibility.js | 165 +++++ .../tests/doubles/ownership-matching.js | 18 + .../__tests__/authorization-ownership.test.js | 248 +++++++ .../authorize-entity-for-user.test.js | 62 ++ .../__tests__/entity-scope-use-cases.test.js | 281 +++++++ ...rocess-authorization-callback-logs.test.js | 8 +- .../process-authorization-callback.test.js | 16 +- .../use-cases/authorize-entity-for-user.js | 59 ++ .../use-cases/entity-operations-for-user.js | 52 +- .../entity-operations-for-user.test.js | 19 +- .../core/modules/use-cases/entity-types.js | 38 +- .../use-cases/execute-entity-proxy-request.js | 15 + .../execute-entity-proxy-request.test.js | 17 +- .../use-cases/get-entities-for-user.js | 21 +- .../use-cases/get-entity-module-for-user.js | 26 +- .../use-cases/get-entity-options-by-id.js | 7 +- packages/core/modules/use-cases/get-module.js | 7 +- .../use-cases/multi-step-authorization.js | 48 +- .../multi-step-authorization.test.js | 20 +- .../process-authorization-callback.js | 70 +- .../use-cases/refresh-entity-options.js | 7 +- .../modules/use-cases/test-module-auth.js | 7 +- packages/core/prisma-mongodb/schema.prisma | 15 + .../migration.sql | 25 + packages/core/prisma-postgresql/schema.prisma | 15 + packages/core/types/modules/index.d.ts | 87 +++ .../schemas/api-authorization.schema.json | 5 + .../schemas/schemas/api-entities.schema.json | 31 + .../schemas/app-definition.schema.json | 42 ++ .../integration-definition.schema.json | 51 ++ .../tests/app-definition-ownership.test.js | 81 ++ 80 files changed, 5510 insertions(+), 545 deletions(-) create mode 100644 docs/guides/entity-scopes.md create mode 100644 packages/core/database/organization-where.js create mode 100644 packages/core/integrations/repositories/integration-not-found.js create mode 100644 packages/core/integrations/repositories/integration-repository-ownership.test.js create mode 100644 packages/core/integrations/tests/integration-router-ownership.test.js create mode 100644 packages/core/integrations/tests/use-cases/integration-ownership.test.js create mode 100644 packages/core/integrations/use-cases/resolve-integration-ownership.js create mode 100644 packages/core/management-api/v2/entity-scopes.test.js create mode 100644 packages/core/management-api/v2/request.test.js create mode 100644 packages/core/modules/entity-scope.js create mode 100644 packages/core/modules/entity-scope.test.js create mode 100644 packages/core/modules/repositories/__tests__/module-repository-ownership.test.js create mode 100644 packages/core/modules/scope-config.js create mode 100644 packages/core/modules/scope-policy.js create mode 100644 packages/core/modules/scope-resolution.js create mode 100644 packages/core/modules/scope-visibility.js create mode 100644 packages/core/modules/tests/doubles/ownership-matching.js create mode 100644 packages/core/modules/use-cases/__tests__/authorization-ownership.test.js create mode 100644 packages/core/modules/use-cases/__tests__/authorize-entity-for-user.test.js create mode 100644 packages/core/modules/use-cases/__tests__/entity-scope-use-cases.test.js create mode 100644 packages/core/modules/use-cases/authorize-entity-for-user.js create mode 100644 packages/core/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql create mode 100644 packages/schemas/tests/app-definition-ownership.test.js diff --git a/docs/guides/entity-scopes.md b/docs/guides/entity-scopes.md new file mode 100644 index 000000000..c41d6a1d8 --- /dev/null +++ b/docs/guides/entity-scopes.md @@ -0,0 +1,185 @@ +# Entity scopes and ownership + +ADR-056 §3.1-§3.4 and the entity, integration and list parts of §3.6. Code: `packages/core/modules/scope-*.js` +(re-exported by `entity-scope.js`), used by the use cases for entities, integrations and authorization. + +| File | Holds | +|---|---| +| `scope-resolution.js` | constants, scope resolution (`decideLevel`, `applyRequest`), ownership on create, `pickExistingEntity` | +| `scope-config.js` | reading and validating `ownership` settings at app-definition load | +| `scope-visibility.js` | the clauses a caller may see, `describeRecord`, list filters | +| `scope-policy.js` | `createScopePolicy`, the object a use case asks (`resolveOwnership`, `canUseEntity`, `accessContext`) | + +## The model + +Every entity (a connected account) has one **scope**: + +| Scope | Owner (`userId`) | `organizationId` | Who sees it | +|---|---|---|---| +| `individual` | the person's individual user | the organization active when it was connected; null under `individualAcross: 'all'` or when connected with no organization | that person, in that organization | +| `organization` | the active organization's user | the same organization | every member of it | +| `global` | none | null | nobody through the tenant API (admin routes only, not built here) | + +There is **no `scope` column**. The owner's user type already says it, so a stored row's scope is read, not +stored: no owner is `global`, the active organization's user is `organization`, the caller's individual user is +`individual`. (ADR-0016 §5 allows this; the `scope` enum and `[scope, moduleName]` index of ADR-056 come with +the `global` entity work, because `global` is the only scope the owner cannot tell.) Adding the column later is a +backfill of three values from the owner. + +Integrations are owned by the person or the organization too, and carry `organizationId`: the active organization +at create time. + +## Where a scope comes from + +First match wins, each level overriding those below it; nothing intersects (§3.1): + +1. request: `scope` on `POST /api/v2/authorize`, only from the deciding level's `allow` list +2. binding: `Definition.modules[key].scope` (what `use(module, { scope })` will set) or `Definition.entities[key].scope` +3. integration: `Definition.ownership.scope` +4. entity type: `appDefinition.entities[name].scope` (ADR-059 registry, `settings.scope`) +5. app: `appDefinition.ownership.scope` +6. default: `organization` when `user.primary` is `organization`, else `individual` + +A setting is a scope or `{ default, allow }`. A request for `global`, or for a scope outside the deciding level's +`allow` list, answers 403 `SCOPE_NOT_ALLOWED` (the list is in `details.allowed`). `GET /api/v2/entities/types` +shows each type's `scope: { default, allow, source }` so a client can offer the choice. + +Everything is validated at app-definition load (`loadAppDefinition` throws `OwnershipConfigError`): a bad scope, +an unknown `individualAcross`, an unknown `owner`, or an organization-owned integration that binds an +`individual` entity (§3.3). `listBindingScopes(appDefinition, registry, primary)` returns every binding's +resolved scope and its source; nothing prints it yet. + +## Ownership on create + +- `individual`: owner is the person (400 `IDENTITY_REQUIRED` naming `x-frigg-appuserid` if the request has no + individual); `organizationId` is the active organization, or null under `individualAcross: 'all'`. +- `organization`: owner is the active organization's user (400 naming `x-frigg-apporgid` if none). Anyone in the + organization may create one: there is no organization-admin check yet. +- `global`: refused (403) with a message saying the app owner creates them through admin routes. + +This replaces `user.getId()` as the owner. `POST /api/v2/authorize` and the v1 `POST /api/authorize` both go +through `AuthorizeEntityForUser`. The module's `getEntityDetails` and `getCredentialDetails` get the **owner's** id +as their user id, and the credential is stored under it. + +**An integration must use entities of one scope.** Its owner is the organization when every entity is +organization-scoped, otherwise the person (or what `Definition.ownership.owner` says). An integration is loaded by +its owner, who must own what it uses, so `ResolveIntegrationOwnership` refuses, with a 400 and **before anything is +written**, an integration that: + +- mixes an organization entity with the person's own (`MIXED_ENTITY_SCOPES`), or names `owner: 'individual'` over + organization entities; +- is organization-owned and uses an individual entity (`ORG_INTEGRATION_INDIVIDUAL_ENTITY`; the load-time check + covers the declared scopes, this one the entities actually named); +- uses an entity whose scope the binding or `Definition.ownership.scope` does not accept (`ENTITY_SCOPE_MISMATCH`). + +A global entity, an entity of another organization, and one that does not exist are all the same 404. An integration +that uses a global entity, or a person's and the organization's together, is a later change. + +`CreateIntegration` also loads every entity's module before it writes the integration row, so a failure there leaves +no `IN_CREATION` row behind. A duplicate create (same owner, type, entities) is reused only within one organization; +see the legacy rules below for rows with none. + +## Lists and access (§3.4, §3.6) + +For the active organization, the caller sees: + +``` +organization entities of the active organization +owner = caller AND (organizationId = active + OR (individualAcross = 'all' AND organizationId IS NULL)) -- entities only +integrations owned by the caller or by the active organization, created in it +``` + +The same clause list (`ctx.clauses`, built once per request) is sent to the repository as one query +(`findEntitiesByOwnership`, `findIntegrationsByOwnership`: an OR of owner/organization pairs) and applied again in +memory (`describeRecord`) to every row returned, so a list and an access check cannot disagree, and a repository +that returns too much cannot widen a list. The check applies wherever an entity is loaded for use: creating an +integration, test-auth, options, delete, and the v1 entity routes. The v1 routes on one integration (get, patch, +delete, actions, test-auth) reach it through the same rule. + +`/entities` and `/integrations` carry `scope`, `owner: { type, id }`, `organizationId` and `access` on every record, +and take `?scope=individual|organization` and `?owner=me|org` (a bad value is 400). `access` is `manage` for the +person's own and the organization's (there are no organization roles yet) and `use` where only use is possible. + +**Credentials are not scoped in this change.** `/api/v2/credentials` still lists and fetches by owner, as before, +so a credential owned by an organization user is not listed to an individual-primary caller. Listing credentials +through the entities the caller may see is the next change. + +**The entity proxy fails closed.** Until `managementApi.proxy.scopes` exists (F6), the proxy accepts only +`individual` entities and answers 403 `PROXY_NOT_ALLOWED` (`reason: 'scope'`) for an organization one: ADR-056 §3.5, +narrowest by default. An organization-primary app, whose entities are organization-scoped by default, cannot proxy +until it opts in with `proxy.scopes` once F6 lands. + +## Legacy rows: an existing deployment keeps its entities + +Rows written before `organizationId` existed have it unset. Nothing is migrated; the rule reads them: + +| Row | Behaviour | +|---|---| +| owned by an organization user, no `organizationId` | the organization's own: the owner is unambiguous | +| owned by a person, request has **no** organization | visible, exactly as before | +| owned by a person, request has an organization | belongs to the person's legacy `IndividualUser.organizationId` pointer: visible there and nowhere else | +| integration with no `organizationId` | the same two person rules (never organization-independent, not even under `'all'`) | +| entity with no `organizationId` under `individualAcross: 'all'` | the person's, in every organization (null is what `'all'` writes) | + +An app with no organizations sees the same rows through the same routes. An organization-primary app sees what it +saw, because those rows were owned by the organization user (`getId()`) and the person's entities show in the +organization their pointer names. Before ADR-056 §3.6 the pointer moved to the last organization a person used, so +someone who worked in two organizations before the upgrade finds their legacy rows in the last one. + +**Creating the same integration again** returns the existing one, including a legacy one. An organization-owned +integration adopts a row with no `organizationId` whoever asks and whatever their own pointer says (its owner is the +organization); a person's adopts one only where the rows above would show it. Without that, an organization-primary +caller sending only `x-frigg-apporgid`, or whose pointer names another organization, would get a duplicate +integration and a second `ON_CREATE`. + +Reauthorizing a legacy entity reuses it where the rule would show it; elsewhere it creates a new entity paired with +the organization ("the same account in two organizations is two entities"). + +Tests that pin this: `modules/entity-scope.test.js` ("legacy"), `modules/use-cases/__tests__/entity-scope-use-cases.test.js` +("legacy rows"), `integrations/tests/use-cases/integration-ownership.test.js` ("creating twice is idempotent..."). + +## Schema and release notes + +`Entity.organizationId` and `Integration.organizationId`: nullable, in both Prisma schemas, with an index on +`[userId, organizationId]`, and one PostgreSQL migration (`20261016000000_add_entity_integration_organization`: +two `ADD COLUMN`s and two indexes, checked against `prisma migrate diff`). No data migration. Deliberately not a +relation to `User`: a deleted organization must leave its rows invisible, and `ON DELETE SET NULL` would turn them +into unscoped ones that the rules above then show. + +- **Index lock.** The two `CREATE INDEX` statements are plain, not `CONCURRENTLY` (Prisma runs a migration in a + transaction). Each takes a SHARE lock while it builds, so writes to `Entity` and `Integration` wait. Both tables are + small in a typical deployment. On a very large one, run `CREATE INDEX CONCURRENTLY IF NOT EXISTS` with the same names + first, then deploy. +- **v1 integration create** used to check no entity ownership at all (any caller could bind any entity id). It now + runs the same `ResolveIntegrationOwnership` as v2 and answers 404 for an entity the caller may not use. +- **Entity proxy** refuses organization-scoped entities (see above). +- **Mixed integrations** (an organization entity with the person's own) are refused with a 400; before this change + entity ownership was never checked, so they were neither refused nor meaningful. + +On MongoDB a row without the field and a row with `null` are different to Prisma (`null` matches only an explicit +null), so every "no organization" match is `null OR { isSet: false }`. DocumentDB's raw `{ field: null }` matches +both. + +## Things that bite + +- **A provider field called `organizationId` is not ours.** `createEntity(entityData, { organizationId })` takes the + organization as a separate argument, and the repositories write `organizationId` after the entity data when they + return a row, so provider data cannot decide who sees an entity. +- **`OwnershipClause.userId` can hold an organization's id.** It is the owner's user id, and an organization-owned row + is owned by the ORGANIZATION user. +- Binding scope is **enforced** only at the binding and integration levels (2 and 3). Entity type, app and default + decide where a connection is created, so adding a registry scope later does not break integrations that already + use older connections. +- A missing integration is a 404 through one predicate (`integration-not-found.js`), which the repositories use to + build the error and the use case to match it. Entity and credential lookups keep their previous behaviour. + +## Deferred + +- `managementApi.proxy.scopes` and the real-Postgres isolation suite (F6). +- Organization roles (who may create, change or delete organization-level things), credential scoping, and the + `ORPHANED` status with removing a member. +- `GET /api/v2/me/organizations`, the org-admin `view` of members' individual entities. +- Integrations that use global entities or both scopes, binding resolution at integration create (picking the entity + for a binding when the caller names none), the visibility callback, `global` entity admin routes and the `scope` + column. diff --git a/docs/guides/organization-memberships.md b/docs/guides/organization-memberships.md index 0f785a7f9..089bba35d 100644 --- a/docs/guides/organization-memberships.md +++ b/docs/guides/organization-memberships.md @@ -76,10 +76,9 @@ composes them. ## What is not done here -- **Active-organization scoping of ownership is F5.** Credentials, entities and integrations are still - owned by `user.getId()`, which is the individual under the default `primary: 'individual'`, so the - active organization does not yet scope them (it already decides `getId()` under - `primary: 'organization'`). `organizationId` on Entity and Integration, list filtering, - `GET /api/v2/me/organizations` and removing a member are the next work packages. +- **Scoping of ownership by the active organization is done** (see [Entity scopes](./entity-scopes.md)): + entities and integrations carry `organizationId` and lists follow the active organization. + `GET /api/v2/me/organizations` and removing a member (which orphans that membership's integrations) + are the next work packages. - Deleting a user does not delete its memberships on MongoDB (no referential integrity); PostgreSQL cascades. diff --git a/packages/core/README.md b/packages/core/README.md index 98ce60931..ca88be8cb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -421,6 +421,42 @@ Rules worth knowing: - Two first logins for the same new person or organization, even in different organizations, both succeed: a create that loses to a unique constraint finds the winner's row. +#### Entity scopes and ownership (ADR-056 §3) + +Who owns an entity (a connected account) or an integration follows its **scope**, not `user.primary`: + +| Scope | Owned by | Created with | +|---|---|---| +| `individual` | the person (`individualUser`) | the active organization (`organizationId`), or none under `individualAcross: 'all'` | +| `organization` | the active organization's user | that organization | +| `global` | nobody (the app owner) | none; refused by the tenant API (admin routes only) | + +The scope of a new connection is the first of: the request (`scope` on `POST /api/v2/authorize`, only from +the deciding level's `allow` list), the integration binding (`Definition.modules[key].scope` or +`Definition.entities[key].scope`), `Definition.ownership.scope`, the entity type +(`appDefinition.entities[name].scope`), `appDefinition.ownership.scope`, then `organization` for an +organization-primary app and `individual` otherwise. A setting is `'organization'` or +`{ default: 'individual', allow: ['individual', 'organization'] }`. + +```javascript +const appDefinition = { + user: { primary: 'individual', authModes: { adopterJwt: true } }, + ownership: { scope: 'individual', individualAcross: 'organization' }, // the defaults + entities: { + google: { definition: google, scope: 'individual' }, + xero: { definition: xero, scope: { default: 'organization', allow: ['organization', 'individual'] } }, + }, + integrations: [Sync], // Sync.Definition.ownership = { owner: 'derive' } +}; +``` + +`GET /api/v2/entities` and `/integrations` return, for the active organization, its own +rows and the caller's, each with `scope`, `owner`, `organizationId` and `access`, and take `?scope=` and +`?owner=me|org`. An individual entity is only visible and usable in the organization it was connected in +(or in all of them, with `individualAcross: 'all'`). Rows written before this existed keep working: see +[Entity scopes](../../docs/guides/entity-scopes.md), which also covers the schema (`organizationId` on +`Entity` and `Integration`, no `scope` column) and what is deferred. + ### 8. Lambda Utilities (`/lambda`) AWS Lambda-specific utilities and helpers. diff --git a/packages/core/application/commands/integration-commands.js b/packages/core/application/commands/integration-commands.js index 18375c2a9..420e3ffc4 100644 --- a/packages/core/application/commands/integration-commands.js +++ b/packages/core/application/commands/integration-commands.js @@ -18,7 +18,7 @@ const { ListIntegrationsByEntityExternalIdUseCase, } = require('../../integrations/use-cases/list-integrations-by-entity-external-id'); const { - GetIntegrationsForUser, + GetIntegrationsByUserId, } = require('../../integrations/use-cases/get-integrations-for-user'); const { CreateIntegration, @@ -152,7 +152,7 @@ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {}) moduleRepository, }); - const getIntegrationsForUserUseCase = new GetIntegrationsForUser({ + const getIntegrationsForUserUseCase = new GetIntegrationsByUserId({ integrationRepository, integrationClasses: [integrationClass], moduleFactory, diff --git a/packages/core/database/organization-where.js b/packages/core/database/organization-where.js new file mode 100644 index 000000000..43dd69968 --- /dev/null +++ b/packages/core/database/organization-where.js @@ -0,0 +1,18 @@ +/** + * The Prisma `where` fragment for an `organizationId` on MongoDB (ADR-056 + * §3.6), shared by the entity and integration adapters. + * + * Prisma on MongoDB: `null` matches only an explicit null and `isSet: false` + * only a missing field. A row written before the column existed is the common + * "no organization" case, so `null` here means both. + */ +function organizationWhere(organizationId) { + if (organizationId === null) { + return { + OR: [{ organizationId: null }, { organizationId: { isSet: false } }], + }; + } + return { organizationId }; +} + +module.exports = { organizationWhere }; diff --git a/packages/core/handlers/app-definition-loader.js b/packages/core/handlers/app-definition-loader.js index a928b6c57..01103b817 100644 --- a/packages/core/handlers/app-definition-loader.js +++ b/packages/core/handlers/app-definition-loader.js @@ -11,6 +11,7 @@ const { getEntityTypeRegistry, getEntityTypeDefinitions, } = require('../modules/entity-type-registry'); +const { resolveOwnershipConfig } = require('../modules/entity-scope'); // Handlers without a database never load the encryption registry, so the // credential leaf keys are registered for redaction here too. @@ -33,10 +34,11 @@ function registerCredentialLogKeys(appDefinition, moduleDefinitions) { * @function loadAppDefinition * @description Searches for the nearest backend package.json, loads the corresponding index.js file, * and extracts the application definition containing integrations and user configuration. - * @returns {{integrations: Array, entities: object | null, entityTypeRegistry: Map, moduleDefinitions: Array, userConfig: object | null, adminScripts: Array, reports: Array, admin: object, telemetry: object, logging: object | null, managementApi: object}} An object containing the application definition. + * @returns {{integrations: Array, entities: object | null, entityTypeRegistry: Map, moduleDefinitions: Array, ownership: {scope: ({default: string, allow: string[]}|null), individualAcross: string}, userConfig: object | null, adminScripts: Array, reports: Array, admin: object, telemetry: object, logging: object | null, managementApi: object}} An object containing the application definition. * @throws {Error} Throws error if backend package.json cannot be found. * @throws {Error} Throws error if index.js file cannot be found in the backend directory. * @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig. + * @throws {OwnershipConfigError} Throws if an ownership or scope setting is invalid (ADR-056 §3.1). * @example * const { integrations, userConfig, telemetry } = loadAppDefinition(); * console.log(`Found ${integrations.length} integrations`); @@ -77,6 +79,11 @@ function loadAppDefinition() { const entityTypeRegistry = getEntityTypeRegistry(appDefinition); const moduleDefinitions = getEntityTypeDefinitions(entityTypeRegistry); + // Same reason for entity scopes (ADR-056 §3.1): a mistyped scope, an unknown + // `individualAcross`, or an organization-owned integration that binds an + // individual entity stops the app at load. + const ownership = resolveOwnershipConfig(appDefinition, entityTypeRegistry); + registerCredentialLogKeys(appDefinition, moduleDefinitions); // Degrade consistently: an invalid telemetry block must never take down a @@ -105,6 +112,7 @@ function loadAppDefinition() { entities, entityTypeRegistry, moduleDefinitions, + ownership, userConfig, adminScripts, reports, diff --git a/packages/core/handlers/app-definition-loader.test.js b/packages/core/handlers/app-definition-loader.test.js index 19567aba2..e05df0320 100644 --- a/packages/core/handlers/app-definition-loader.test.js +++ b/packages/core/handlers/app-definition-loader.test.js @@ -9,6 +9,7 @@ jest.mock('@friggframework/core/utils', () => ({ const { findNearestBackendPackageJson } = require('@friggframework/core/utils'); const { loadAppDefinition } = require('./app-definition-loader'); const { EntityRegistryError } = require('../modules/entity-type-registry'); +const { OwnershipConfigError } = require('../modules/entity-scope'); const { isDeniedKey } = require('../logs/redact'); const { createMemorySink } = require('../logs'); @@ -234,4 +235,46 @@ describe('loadAppDefinition', () => { expect(() => loadAppDefinition()).toThrow(message); }); }); + + describe('ownership and entity scopes (ADR-056 §3.1)', () => { + const hubspot = { moduleName: 'hubspot' }; + const sync = (extra = {}) => ({ + Definition: { name: 'sync', modules: { crm: { definition: hubspot } }, ...extra }, + }); + + it('returns the resolved app-level ownership, defaulting to organization-wide individuals', () => { + dirs.push(writeBackend({ integrations: [] })); + expect(loadAppDefinition().ownership).toEqual({ scope: null, individualAcross: 'organization' }); + + dirs.push( + writeBackend({ + integrations: [], + ownership: { scope: { default: 'organization', allow: ['organization', 'individual'] }, individualAcross: 'all' }, + }) + ); + expect(loadAppDefinition().ownership).toEqual({ + scope: { default: 'organization', allow: ['organization', 'individual'] }, + individualAcross: 'all', + }); + }); + + it.each([ + ['an unknown individualAcross', { ownership: { individualAcross: 'everywhere' } }, /individualAcross/], + ['a scope that is not one', { ownership: { scope: 'team' } }, /appDefinition.ownership.scope/], + [ + 'a registry scope that is not one', + { integrations: [], entities: { hubspot: { definition: hubspot, scope: 'team' } } }, + /entity type 'hubspot' scope/, + ], + [ + 'an organization-owned integration that binds an individual entity', + { user: { primary: 'individual' }, integrations: [sync({ ownership: { owner: 'organization' } })] }, + /cannot bind an 'individual' entity/, + ], + ])('fails at load, not on the first request, for %s', (_label, extra, message) => { + dirs.push(writeBackend({ integrations: [sync()], ...extra })); + expect(() => loadAppDefinition()).toThrow(OwnershipConfigError); + expect(() => loadAppDefinition()).toThrow(message); + }); + }); }); diff --git a/packages/core/index.js b/packages/core/index.js index 84847823c..585051d3f 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -108,6 +108,9 @@ const { ModuleFactory, getEntityTypeRegistry, EntityRegistryError, + resolveScope, + listBindingScopes, + OwnershipConfigError, } = require('./modules/index'); const application = require('./application'); const utils = require('./utils'); @@ -220,6 +223,9 @@ module.exports = { ModuleFactory, getEntityTypeRegistry, EntityRegistryError, + resolveScope, + listBindingScopes, + OwnershipConfigError, // queues QueuerUtil, diff --git a/packages/core/integrations/integration-router.js b/packages/core/integrations/integration-router.js index 7a9db9588..776b07ad8 100644 --- a/packages/core/integrations/integration-router.js +++ b/packages/core/integrations/integration-router.js @@ -61,6 +61,14 @@ const { const { ProcessAuthorizationCallback, } = require('../modules/use-cases/process-authorization-callback'); +const { createScopePolicy } = require('../modules/scope-policy'); +const { + AuthorizeEntityForUser, +} = require('../modules/use-cases/authorize-entity-for-user'); +const { + ResolveIntegrationOwnership, +} = require('./use-cases/resolve-integration-ownership'); +const { GetOwnedIntegrationRecord } = require('./use-cases/integration-operations-for-user'); const log = getLogger('frigg.integrations'); @@ -71,7 +79,11 @@ function createIntegrationRouter() { integrations: integrationClasses, userConfig, moduleDefinitions, + ownership, + entityTypeRegistry, } = loadAppDefinition(); + // Ownership by scope (ADR-056 §3): one object every use case asks. + const scopePolicy = createScopePolicy({ ownership, entityTypeRegistry }); const moduleRepository = createModuleRepository(); const integrationRepository = createIntegrationRepository(); const credentialRepository = createCredentialRepository(); @@ -107,6 +119,19 @@ function createIntegrationRouter() { integrationClasses, moduleFactory, moduleRepository, + scopePolicy, + }); + + // Decides who owns a new integration and which entities the caller may bind + // (the v1 create used to check neither), and who may reach an existing one. + const resolveIntegrationOwnership = new ResolveIntegrationOwnership({ + moduleRepository, + integrationClasses, + scopePolicy, + }); + const getOwnedIntegrationRecord = new GetOwnedIntegrationRecord({ + integrationRepository, + scopePolicy, }); const getCredentialForUser = new GetCredentialForUser({ @@ -122,6 +147,7 @@ function createIntegrationRouter() { const getEntitiesForUser = new GetEntitiesForUser({ moduleRepository, moduleDefinitions, + scopePolicy, }); const getIntegrationInstance = new GetIntegrationInstance({ @@ -147,37 +173,47 @@ function createIntegrationRouter() { const testModuleAuth = new TestModuleAuth({ moduleRepository, moduleDefinitions, + scopePolicy, }); const getModule = new GetModule({ moduleRepository, moduleDefinitions, + scopePolicy, }); const getEntityOptionsById = new GetEntityOptionsById({ moduleRepository, moduleDefinitions, + scopePolicy, }); const refreshEntityOptions = new RefreshEntityOptions({ moduleRepository, moduleDefinitions, + scopePolicy, }); const getPossibleIntegrations = new GetPossibleIntegrations({ integrationClasses, }); - const processAuthorizationCallback = new ProcessAuthorizationCallback({ - moduleRepository, - credentialRepository, - integrationRepository, - moduleDefinitions, + // The owner of what an authorization creates follows its scope (ADR-056 §3.2). + const authorizeEntityForUser = new AuthorizeEntityForUser({ + scopePolicy, + processAuthorizationCallback: new ProcessAuthorizationCallback({ + moduleRepository, + credentialRepository, + integrationRepository, + moduleDefinitions, + }), }); const router = express(); setIntegrationRoutes(router, authenticateUser, { + resolveIntegrationOwnership, + getOwnedIntegrationRecord, createIntegration, deleteIntegrationForUser, getIntegrationsForUser, @@ -194,7 +230,7 @@ function createIntegrationRouter() { getModule, getEntityOptionsById, refreshEntityOptions, - processAuthorizationCallback, + authorizeEntityForUser, }); return router; } @@ -231,6 +267,8 @@ function checkRequiredParams(params, requiredKeys) { */ function setIntegrationRoutes(router, authenticateUser, useCases) { const { + resolveIntegrationOwnership, + getOwnedIntegrationRecord, createIntegration, deleteIntegrationForUser, getIntegrationsForUser, @@ -239,15 +277,20 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { updateIntegration, getPossibleIntegrations, } = useCases; + + // The owner of an integration the caller may reach (404 otherwise), which is + // what the id-and-owner use cases below expect. + const ownerOf = async (integrationId, user) => + (await getOwnedIntegrationRecord.execute(integrationId, user)).userId; + router.route('/api/integrations').get( catchAsyncError(async (req, res) => { const user = await authenticateUser.execute(req); - const userId = user.getId(); - const integrations = await getIntegrationsForUser.execute(userId); + const integrations = await getIntegrationsForUser.execute(user); const results = { entities: { options: await getPossibleIntegrations.execute(), - authorized: await getEntitiesForUser.execute(userId), + authorized: await getEntitiesForUser.execute(user), }, integrations: integrations, }; @@ -259,7 +302,6 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { router.route('/api/integrations').post( catchAsyncError(async (req, res) => { const user = await authenticateUser.execute(req); - const userId = user.getId(); const params = checkRequiredParams(req.body, [ 'entities', 'config', @@ -267,10 +309,17 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { get(params.config, 'type'); + const { ownerId, organizationId, adoptUnscoped } = + await resolveIntegrationOwnership.execute({ + entityIds: params.entities, + config: params.config, + user, + }); const integration = await createIntegration.execute( params.entities, - userId, - params.config + ownerId, + params.config, + { organizationId, adoptUnscoped } ); res.status(201).json(integration); @@ -280,12 +329,11 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { router.route('/api/integrations/:integrationId').patch( catchAsyncError(async (req, res) => { const user = await authenticateUser.execute(req); - const userId = user.getId(); const params = checkRequiredParams(req.body, ['config']); const integration = await updateIntegration.execute( req.params.integrationId, - userId, + await ownerOf(req.params.integrationId, user), params.config ); res.json(integration); @@ -298,7 +346,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { const params = checkRequiredParams(req.params, ['integrationId']); await deleteIntegrationForUser.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.status(204).json({}); }) @@ -310,7 +358,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { const params = checkRequiredParams(req.params, ['integrationId']); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json(await integration.send('GET_CONFIG_OPTIONS')); }) @@ -326,7 +374,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { ]); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json( @@ -340,7 +388,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { const params = checkRequiredParams(req.params, ['integrationId']); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json(await integration.send('GET_USER_ACTIONS', req.body)); }) @@ -357,7 +405,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { ]); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json( @@ -382,7 +430,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { ]); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json( @@ -403,7 +451,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { ]); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json(await integration.send(params.actionId, req.body)); }) @@ -420,7 +468,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { const params = checkRequiredParams(req.params, ['integrationId']); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); // We could perhaps augment router with dynamic options? Haven't decided yet, but here may be the place @@ -440,7 +488,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { const params = checkRequiredParams(req.params, ['integrationId']); const instance = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); if (!instance) { @@ -478,7 +526,7 @@ function setEntityRoutes(router, authenticateUser, useCases) { getModule, getEntityOptionsById, refreshEntityOptions, - processAuthorizationCallback, + authorizeEntityForUser, } = useCases; router.route('/api/authorize').get( @@ -506,11 +554,15 @@ function setEntityRoutes(router, authenticateUser, useCases) { router.route('/api/authorize').post( catchAsyncError(async (req, res) => { const user = await authenticateUser.execute(req); - const userId = user.getId(); const params = checkRequiredParams(req.body, [ 'entityType', 'data', ]); + const ownership = authorizeEntityForUser.resolveOwnership({ + user, + entityType: params.entityType, + }); + const userId = ownership.ownerId; const dataKeys = params.data && typeof params.data === 'object' @@ -522,11 +574,13 @@ function setEntityRoutes(router, authenticateUser, useCases) { dataKeys, }); - const entityDetails = await processAuthorizationCallback.execute( - userId, - params.entityType, - params.data - ); + const { result: entityDetails } = + await authorizeEntityForUser.execute({ + user, + entityType: params.entityType, + data: params.data, + ownership, + }); log.info('Entity authorized', { eventName: 'frigg.integrations.authorized', diff --git a/packages/core/integrations/repositories/integration-not-found.js b/packages/core/integrations/repositories/integration-not-found.js new file mode 100644 index 000000000..0af98e98e --- /dev/null +++ b/packages/core/integrations/repositories/integration-not-found.js @@ -0,0 +1,14 @@ +/** + * The error `findIntegrationById` throws for an id with no row, and the one + * predicate that recognises it. The adapters build the error here and the + * use cases match it here, so the message is written once and a missing + * integration can be told from any other failure without widening a regex. + */ +const MESSAGE = /^Integration with id .+ not found$/; + +const integrationNotFoundError = (id) => + new Error(`Integration with id ${id} not found`); + +const isIntegrationNotFound = (error) => MESSAGE.test(error?.message || ''); + +module.exports = { integrationNotFoundError, isIntegrationNotFound }; diff --git a/packages/core/integrations/repositories/integration-repository-documentdb.js b/packages/core/integrations/repositories/integration-repository-documentdb.js index 705ba2f19..58af2e9fb 100644 --- a/packages/core/integrations/repositories/integration-repository-documentdb.js +++ b/packages/core/integrations/repositories/integration-repository-documentdb.js @@ -14,6 +14,7 @@ const { IntegrationRepositoryInterface, } = require('./integration-repository-interface'); const { validateConfigPatch } = require('./config-patch-shared'); +const { integrationNotFoundError } = require('./integration-not-found'); class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface { constructor() { @@ -28,6 +29,27 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface { return records.map((doc) => this._mapIntegration(doc)); } + /** + * Find integrations matching any of the owner/organization pairs, in one + * query. `organizationId: null` matches rows with no organization (a raw + * MongoDB `null` also matches a missing field). The caller (a use case) + * decides which pairs; this only runs them. + * + * @param {Array<{userId: string, organizationId: (string|null)}>} clauses + * @returns {Promise} + */ + async findIntegrationsByOwnership(clauses) { + if (!Array.isArray(clauses) || clauses.length === 0) return []; + const records = await findMany(this.prisma, 'Integration', { + $or: clauses.map(({ userId, organizationId }) => ({ + userId: toObjectId(userId), + organizationId: + organizationId === null ? null : toObjectId(organizationId), + })), + }); + return records.map((doc) => this._mapIntegration(doc)); + } + async findIntegrations({ type, status } = {}) { const filter = {}; if (type) { @@ -68,11 +90,11 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface { async findIntegrationById(id) { const objectId = toObjectId(id); if (!objectId) { - throw new Error(`Integration with id ${id} not found`); + throw integrationNotFoundError(id); } const doc = await findOne(this.prisma, 'Integration', { _id: objectId }); if (!doc) { - throw new Error(`Integration with id ${id} not found`); + throw integrationNotFoundError(id); } return this._mapIntegration(doc); } @@ -132,10 +154,18 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface { return true; } - async createIntegration(entities, userId, config) { + /** + * @param {Array} entities - entity ids + * @param {string} userId - owner (the person, or the organization's user) + * @param {Object} config - Integration configuration + * @param {{organizationId?: (string|null)}} [tenancy] - the organization the + * integration is created in (ADR-056 §3.6) + */ + async createIntegration(entities, userId, config, tenancy = {}) { const now = new Date(); const document = { userId: toObjectId(userId) || null, + organizationId: toObjectId(tenancy.organizationId) || null, config, version: '0.0.0', status: 'IN_CREATION', @@ -298,6 +328,7 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface { id: fromObjectId(doc?._id), entitiesIds: (doc?.entityIds || []).map((value) => fromObjectId(value)), userId: fromObjectId(doc?.userId), + organizationId: fromObjectId(doc?.organizationId) ?? null, config: doc?.config ?? null, version: doc?.version ?? null, status: doc?.status ?? null, diff --git a/packages/core/integrations/repositories/integration-repository-interface.js b/packages/core/integrations/repositories/integration-repository-interface.js index fb74db55d..e33baa6d9 100644 --- a/packages/core/integrations/repositories/integration-repository-interface.js +++ b/packages/core/integrations/repositories/integration-repository-interface.js @@ -10,6 +10,20 @@ * @abstract */ class IntegrationRepositoryInterface { + /** + * Find integrations matching any of the owner/organization pairs, in one + * query (ADR-056 §3.6). `organizationId: null` matches rows with no + * organization. Which pairs is decided by the caller + * (modules/entity-scope.js), not here. + * + * @param {Array<{userId: string|number, organizationId: (string|number|null)}>} clauses + * @returns {Promise} Array of integration objects + * @abstract + */ + async findIntegrationsByOwnership(clauses) { + throw new Error('Method findIntegrationsByOwnership must be implemented by subclass'); + } + /** * Find all integrations for a user * @@ -123,10 +137,12 @@ class IntegrationRepositoryInterface { * @param {Array} entities - Array of entity IDs * @param {string|number} userId - User ID * @param {Object} config - Integration configuration + * @param {{organizationId?: (string|number|null)}} [tenancy] - the organization the + * integration is created in (ADR-056 §3.6) * @returns {Promise} Created integration object * @abstract */ - async createIntegration(entities, userId, config) { + async createIntegration(entities, userId, config, tenancy) { throw new Error('Method createIntegration must be implemented by subclass'); } diff --git a/packages/core/integrations/repositories/integration-repository-mongo.js b/packages/core/integrations/repositories/integration-repository-mongo.js index ddcb12ff2..b1285b23a 100644 --- a/packages/core/integrations/repositories/integration-repository-mongo.js +++ b/packages/core/integrations/repositories/integration-repository-mongo.js @@ -1,8 +1,10 @@ const { prisma } = require('../../database/prisma'); +const { organizationWhere } = require('../../database/organization-where'); const { IntegrationRepositoryInterface, } = require('./integration-repository-interface'); const { validateConfigPatch } = require('./config-patch-shared'); +const { integrationNotFoundError } = require('./integration-not-found'); /** * MongoDB Integration Repository Adapter @@ -25,6 +27,25 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { this.prisma = prisma; } + /** + * The one place a integration integration row becomes the record the + * application sees (ids of its entities, its organization). + * @private + */ + _mapIntegration(integration) { + return { + id: integration.id, + entitiesIds: integration.entities.map((e) => e.id), + userId: integration.userId, + organizationId: integration.organizationId ?? null, + config: integration.config, + version: integration.version, + status: integration.status, + messages: integration.messages, + createdAt: integration.createdAt, + }; + } + /** * Find all integrations for a user * Replaces: IntegrationModel.find({ user: userId }).populate('entities') @@ -41,16 +62,32 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { }); // Map to domain objects (maintains same API) - return integrations.map((integration) => ({ - id: integration.id, - entitiesIds: integration.entities.map((e) => e.id), - userId: integration.userId, - config: integration.config, - version: integration.version, - status: integration.status, - messages: integration.messages, - createdAt: integration.createdAt, - })); + return integrations.map((integration) => (this._mapIntegration(integration))); + } + + /** + * Find integrations matching any of the owner/organization pairs, in one + * query. `organizationId: null` matches rows with no organization. The + * caller (a use case) decides which pairs; this only runs them. + * + * @param {Array<{userId: string, organizationId: (string|null)}>} clauses + * @returns {Promise} Array of integration objects + */ + async findIntegrationsByOwnership(clauses) { + if (!Array.isArray(clauses) || clauses.length === 0) return []; + const integrations = await this.prisma.integration.findMany({ + where: { + OR: clauses.map(({ userId, organizationId }) => ({ + userId, + ...organizationWhere(organizationId), + })), + }, + include: { + entities: true, + }, + }); + + return integrations.map((integration) => (this._mapIntegration(integration))); } /** @@ -77,16 +114,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { }, }); - return integrations.map((integration) => ({ - id: integration.id, - entitiesIds: integration.entities.map((e) => e.id), - userId: integration.userId, - config: integration.config, - version: integration.version, - status: integration.status, - messages: integration.messages, - createdAt: integration.createdAt, - })); + return integrations.map((integration) => (this._mapIntegration(integration))); } /** @@ -165,15 +193,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { throw new Error(`Integration with name ${name} not found`); } - return { - id: integration.id, - entitiesIds: integration.entities.map((e) => e.id), - userId: integration.userId, - config: integration.config, - version: integration.version, - status: integration.status, - messages: integration.messages, - }; + return this._mapIntegration(integration); } /** @@ -192,18 +212,10 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { }); if (!integration) { - throw new Error(`Integration with id ${id} not found`); + throw integrationNotFoundError(id); } - return { - id: integration.id, - entitiesIds: integration.entities.map((e) => e.id), - userId: integration.userId, - config: integration.config, - version: integration.version, - status: integration.status, - messages: integration.messages, - }; + return this._mapIntegration(integration); } /** @@ -290,15 +302,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { }, }); - return integrations.map((integration) => ({ - id: integration.id, - entitiesIds: integration.entities.map((e) => e.id), - userId: integration.userId, - config: integration.config, - version: integration.version, - status: integration.status, - messages: integration.messages, - })); + return integrations.map((integration) => (this._mapIntegration(integration))); } /** @@ -310,13 +314,16 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { * @param {Array} entities - Array of entity IDs (MongoDB ObjectIds) * @param {string} userId - User ID (MongoDB ObjectId) * @param {Object} config - Integration configuration + * @param {{organizationId?: (string|null)}} [tenancy] - the organization the + * integration is created in (ADR-056 §3.6) * @returns {Promise} Created integration object */ - async createIntegration(entities, userId, config) { + async createIntegration(entities, userId, config, tenancy = {}) { const data = { config, version: '0.0.0', userId: userId, + organizationId: tenancy.organizationId ?? null, entityIds: entities, }; @@ -327,15 +334,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { }, }); - return { - id: integration.id, - entitiesIds: integration.entities.map((e) => e.id), - userId: integration.userId, - config: integration.config, - version: integration.version, - status: integration.status, - messages: integration.messages, - }; + return this._mapIntegration(integration); } /** @@ -357,15 +356,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { return null; } - return { - id: integration.id, - entitiesIds: integration.entities.map((e) => e.id), - userId: integration.userId, - config: integration.config, - version: integration.version, - status: integration.status, - messages: integration.messages, - }; + return this._mapIntegration(integration); } /** @@ -389,15 +380,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface { }, }); - return { - id: integration.id, - entitiesIds: integration.entities.map((e) => e.id), - userId: integration.userId, - config: integration.config, - version: integration.version, - status: integration.status, - messages: integration.messages, - }; + return this._mapIntegration(integration); } /** diff --git a/packages/core/integrations/repositories/integration-repository-ownership.test.js b/packages/core/integrations/repositories/integration-repository-ownership.test.js new file mode 100644 index 000000000..357e34d19 --- /dev/null +++ b/packages/core/integrations/repositories/integration-repository-ownership.test.js @@ -0,0 +1,138 @@ +/** + * Request-shape tests for the organization column (ADR-056 §3.6): what each + * integration adapter asks its database for and what it writes. + */ +jest.mock('../../database/prisma', () => ({ prisma: { $runCommandRaw: jest.fn() } })); + +const { IntegrationRepositoryPostgres } = require('./integration-repository-postgres'); +const { IntegrationRepositoryMongo } = require('./integration-repository-mongo'); +const { IntegrationRepositoryDocumentDB } = require('./integration-repository-documentdb'); +const { ObjectId } = require('bson'); + +const clauses = [ + { userId: '7', organizationId: '17' }, + { userId: '17', organizationId: '17' }, + { userId: '7', organizationId: null }, +]; + +describe('IntegrationRepositoryPostgres', () => { + const make = (rows = []) => { + const repo = new IntegrationRepositoryPostgres(); + repo.prisma = { + integration: { + findMany: jest.fn().mockResolvedValue(rows), + create: jest.fn(async ({ data }) => ({ + id: 3, + userId: 7, + organizationId: data.organizationId, + config: data.config, + entities: [], + })), + }, + }; + return repo; + }; + + it('findIntegrationsByOwnership is one query: an OR of owner/organization pairs', async () => { + const repo = make([ + { id: 1, userId: 7, organizationId: 17, config: { type: 't' }, entities: [{ id: 5 }], status: 'ENABLED' }, + ]); + const found = await repo.findIntegrationsByOwnership(clauses); + expect(repo.prisma.integration.findMany).toHaveBeenCalledTimes(1); + expect(repo.prisma.integration.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { userId: 7, organizationId: 17 }, + { userId: 17, organizationId: 17 }, + { userId: 7, organizationId: null }, + ], + }, + include: { entities: true }, + }); + expect(found[0]).toMatchObject({ id: '1', userId: '7', organizationId: '17', entitiesIds: ['5'] }); + }); + + it('asks for nothing when there are no clauses', async () => { + const repo = make(); + expect(await repo.findIntegrationsByOwnership([])).toEqual([]); + expect(repo.prisma.integration.findMany).not.toHaveBeenCalled(); + }); + + it('createIntegration stores the organization it is created in', async () => { + const repo = make(); + const created = await repo.createIntegration(['5'], '7', { type: 't' }, { organizationId: '17' }); + expect(repo.prisma.integration.create.mock.calls[0][0].data.organizationId).toBe(17); + expect(created.organizationId).toBe('17'); + }); + + it('createIntegration with no tenancy stores no organization', async () => { + const repo = make(); + await repo.createIntegration(['5'], '7', { type: 't' }); + expect(repo.prisma.integration.create.mock.calls[0][0].data.organizationId).toBeNull(); + }); +}); + +describe('IntegrationRepositoryMongo', () => { + const make = () => { + const repo = new IntegrationRepositoryMongo(); + repo.prisma = { + integration: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(async ({ data }) => ({ id: 'i', ...data, entities: [] })), + }, + }; + return repo; + }; + + it('matches a null organization as null OR unset: rows written before the column have no field', async () => { + const repo = make(); + await repo.findIntegrationsByOwnership(clauses); + expect(repo.prisma.integration.findMany).toHaveBeenCalledTimes(1); + expect(repo.prisma.integration.findMany.mock.calls[0][0].where).toEqual({ + OR: [ + { userId: '7', organizationId: '17' }, + { userId: '17', organizationId: '17' }, + { userId: '7', OR: [{ organizationId: null }, { organizationId: { isSet: false } }] }, + ], + }); + }); + + it('createIntegration stores the organization it is created in', async () => { + const repo = make(); + await repo.createIntegration(['e'], 'u', { type: 't' }, { organizationId: 'o1' }); + expect(repo.prisma.integration.create.mock.calls[0][0].data.organizationId).toBe('o1'); + }); +}); + +describe('IntegrationRepositoryDocumentDB', () => { + const make = () => { + const repo = new IntegrationRepositoryDocumentDB(); + const $runCommandRaw = jest.fn(async (command) => + command.insert ? { ok: 1, n: 1 } : { cursor: { firstBatch: [] } } + ); + repo.prisma = { $runCommandRaw }; + return { repo, $runCommandRaw }; + }; + const u7 = new ObjectId(); + const u17 = new ObjectId(); + + it('findIntegrationsByOwnership is one raw find with $or; null matches null or missing', async () => { + const { repo, $runCommandRaw } = make(); + await repo.findIntegrationsByOwnership([ + { userId: u7.toString(), organizationId: u17.toString() }, + { userId: u7.toString(), organizationId: null }, + ]); + const finds = $runCommandRaw.mock.calls.map(([c]) => c).filter((c) => c.find === 'Integration'); + expect(finds).toHaveLength(1); + const { $or } = finds[0].filter; + expect($or[0].organizationId.toString()).toBe(u17.toString()); + expect($or[1].organizationId).toBeNull(); + }); + + it('createIntegration stores the organization it is created in', async () => { + const { repo, $runCommandRaw } = make(); + await repo.createIntegration([], u7.toString(), { type: 't' }, { organizationId: u17.toString() }).catch(() => {}); + const insert = $runCommandRaw.mock.calls.map(([c]) => c).find((c) => c.insert === 'Integration'); + expect(insert.documents[0].organizationId.toString()).toBe(u17.toString()); + }); +}); diff --git a/packages/core/integrations/repositories/integration-repository-postgres.js b/packages/core/integrations/repositories/integration-repository-postgres.js index 3296bff0f..d9ec801c9 100644 --- a/packages/core/integrations/repositories/integration-repository-postgres.js +++ b/packages/core/integrations/repositories/integration-repository-postgres.js @@ -3,6 +3,7 @@ const { IntegrationRepositoryInterface, } = require('./integration-repository-interface'); const { validateConfigPatch } = require('./config-patch-shared'); +const { integrationNotFoundError } = require('./integration-not-found'); const { strictIntId } = require('./report-id'); /** @@ -51,6 +52,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { ...integration, id: integration.id?.toString(), userId: integration.userId?.toString(), + organizationId: integration.organizationId?.toString() ?? null, entities: integration.entities?.map(e => ({ ...e, id: e.id?.toString(), @@ -60,6 +62,25 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { }; } + /** + * The one place a converted integration row becomes the record the + * application sees (string ids, ids of its entities, its organization). + * @private + */ + _mapIntegration(converted) { + return { + id: converted.id, + entitiesIds: converted.entities.map((e) => e.id), + userId: converted.userId, + organizationId: converted.organizationId, + config: converted.config, + version: converted.version, + status: converted.status, + messages: converted.messages, + createdAt: converted.createdAt, + }; + } + /** * Find all integrations for a user * @@ -78,16 +99,35 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { // Map to domain objects with string IDs return integrations.map((integration) => { const converted = this._convertIntegrationIds(integration); - return { - id: converted.id, - entitiesIds: converted.entities.map((e) => e.id), - userId: converted.userId, - config: converted.config, - version: converted.version, - status: converted.status, - messages: converted.messages, - createdAt: converted.createdAt, - }; + return this._mapIntegration(converted); + }); + } + + /** + * Find integrations matching any of the owner/organization pairs, in one + * query. `organizationId: null` matches rows with no organization. The + * caller (a use case) decides which pairs; this only runs them. + * + * @param {Array<{userId: string, organizationId: (string|null)}>} clauses + * @returns {Promise} Array of integration objects with string IDs + */ + async findIntegrationsByOwnership(clauses) { + if (!Array.isArray(clauses) || clauses.length === 0) return []; + const integrations = await this.prisma.integration.findMany({ + where: { + OR: clauses.map(({ userId, organizationId }) => ({ + userId: this._convertId(userId), + organizationId: this._convertId(organizationId), + })), + }, + include: { + entities: true, + }, + }); + + return integrations.map((integration) => { + const converted = this._convertIntegrationIds(integration); + return this._mapIntegration(converted); }); } @@ -117,16 +157,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { return integrations.map((integration) => { const converted = this._convertIntegrationIds(integration); - return { - id: converted.id, - entitiesIds: converted.entities.map((e) => e.id), - userId: converted.userId, - config: converted.config, - version: converted.version, - status: converted.status, - messages: converted.messages, - createdAt: converted.createdAt, - }; + return this._mapIntegration(converted); }); } @@ -209,15 +240,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { } const converted = this._convertIntegrationIds(integration); - return { - id: converted.id, - entitiesIds: converted.entities.map((e) => e.id), - userId: converted.userId, - config: converted.config, - version: converted.version, - status: converted.status, - messages: converted.messages, - }; + return this._mapIntegration(converted); } /** @@ -236,19 +259,11 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { }); if (!integration) { - throw new Error(`Integration with id ${id} not found`); + throw integrationNotFoundError(id); } const converted = this._convertIntegrationIds(integration); - return { - id: converted.id, - entitiesIds: converted.entities.map((e) => e.id), - userId: converted.userId, - config: converted.config, - version: converted.version, - status: converted.status, - messages: converted.messages, - }; + return this._mapIntegration(converted); } /** @@ -328,12 +343,15 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { * @param {Array} entities - Array of entity IDs (strings from application layer) * @param {string} userId - User ID (string from application layer) * @param {Object} config - Integration configuration + * @param {{organizationId?: (string|null)}} [tenancy] - the organization the + * integration is created in (ADR-056 §3.6) * @returns {Promise} Created integration object with string IDs */ - async createIntegration(entities, userId, config) { + async createIntegration(entities, userId, config, tenancy = {}) { const data = { config, version: '0.0.0', + organizationId: this._convertId(tenancy.organizationId ?? null), }; // PostgreSQL: use nested relations with ID conversion @@ -354,15 +372,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { }); const converted = this._convertIntegrationIds(integration); - return { - id: converted.id, - entitiesIds: converted.entities.map((e) => e.id), - userId: converted.userId, - config: converted.config, - version: converted.version, - status: converted.status, - messages: converted.messages, - }; + return this._mapIntegration(converted); } /** @@ -385,15 +395,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { } const converted = this._convertIntegrationIds(integration); - return { - id: converted.id, - entitiesIds: converted.entities.map((e) => e.id), - userId: converted.userId, - config: converted.config, - version: converted.version, - status: converted.status, - messages: converted.messages, - }; + return this._mapIntegration(converted); } /** @@ -418,15 +420,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { }); const converted = this._convertIntegrationIds(integration); - return { - id: converted.id, - entitiesIds: converted.entities.map((e) => e.id), - userId: converted.userId, - config: converted.config, - version: converted.version, - status: converted.status, - messages: converted.messages, - }; + return this._mapIntegration(converted); } /** @@ -476,15 +470,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface { return integrations.map((integration) => { const converted = this._convertIntegrationIds(integration); - return { - id: converted.id, - entitiesIds: converted.entities.map((e) => e.id), - userId: converted.userId, - config: converted.config, - version: converted.version, - status: converted.status, - messages: converted.messages, - }; + return this._mapIntegration(converted); }); } } diff --git a/packages/core/integrations/repositories/integration-repository-postgres.test.js b/packages/core/integrations/repositories/integration-repository-postgres.test.js index 58698137f..db8ad719b 100644 --- a/packages/core/integrations/repositories/integration-repository-postgres.test.js +++ b/packages/core/integrations/repositories/integration-repository-postgres.test.js @@ -114,6 +114,7 @@ describe('IntegrationRepositoryPostgres.patchIntegrationConfig', () => { id: '7', entitiesIds: ['62'], userId: '1', + organizationId: null, config: { type: 'attio', attioWebhookId: 'wh_1' }, version: '0.0.0', status: 'ENABLED', diff --git a/packages/core/integrations/tests/doubles/test-integration-repository.js b/packages/core/integrations/tests/doubles/test-integration-repository.js index 1018ab991..a8028cfec 100644 --- a/packages/core/integrations/tests/doubles/test-integration-repository.js +++ b/packages/core/integrations/tests/doubles/test-integration-repository.js @@ -1,5 +1,6 @@ const { v4: uuid } = require('uuid'); const { validateConfigPatch } = require('../../repositories/config-patch-shared'); +const { matchesAnyClause } = require('../../../modules/tests/doubles/ownership-matching'); class TestIntegrationRepository { constructor() { @@ -7,13 +8,14 @@ class TestIntegrationRepository { this.operationHistory = []; } - async createIntegration(entities, userId, config) { + async createIntegration(entities, userId, config, tenancy = {}) { const id = uuid(); const record = { id, _id: id, entitiesIds: entities, userId: userId, + organizationId: tenancy.organizationId ?? null, config, version: '0.0.0', status: 'IN_CREATION', @@ -38,6 +40,15 @@ class TestIntegrationRepository { return results; } + // Answers the way a database does for the clauses a use case hands it. + async findIntegrationsByOwnership(clauses) { + const results = Array.from(this.store.values()).filter((r) => + matchesAnyClause(r, clauses) + ); + this.operationHistory.push({ operation: 'findByOwnership', clauses, count: results.length }); + return results; + } + async findIntegrations(filter = {}) { const { type, status } = filter; const results = Array.from(this.store.values()).filter((r) => { diff --git a/packages/core/integrations/tests/integration-router-ownership.test.js b/packages/core/integrations/tests/integration-router-ownership.test.js new file mode 100644 index 000000000..7b510dfab --- /dev/null +++ b/packages/core/integrations/tests/integration-router-ownership.test.js @@ -0,0 +1,194 @@ +/** + * The deprecated v1 routes follow the same ownership rules as v2 (ADR-056 §3, + * §3.6): the owner of what an authorization creates, who may bind an entity, + * and which integrations a caller reaches. Real router, real scope policy and + * real ResolveIntegrationOwnership; the repositories, the callback that talks + * to a provider, and the integration lifecycle are the fakes. + */ +const mockState = { entities: [], integrations: [] }; +const mockSame = (a, b) => + a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b); + +const mockRegistry = new Map([['acme', { settings: { scope: 'organization' } }]]); +jest.mock('../../handlers/app-definition-loader', () => ({ + loadAppDefinition: () => ({ + integrations: [ + { + Definition: { name: 'sync', modules: { acme: { definition: { moduleName: 'acme' } } } }, + getOptionDetails: () => ({}), + }, + ], + userConfig: {}, + moduleDefinitions: [{ moduleName: 'acme', getName: () => 'acme' }], + entityTypeRegistry: mockRegistry, + ownership: { scope: null, individualAcross: 'organization' }, + }), +})); +jest.mock('../repositories/integration-repository-factory', () => ({ + createIntegrationRepository: () => ({ + findIntegrationById: async (id) => { + const row = mockState.integrations.find((i) => i.id === id); + if (!row) throw new Error(`Integration with id ${id} not found`); + return row; + }, + }), +})); +jest.mock('../../credential/repositories/credential-repository-factory', () => ({ + createCredentialRepository: () => ({}), +})); +jest.mock('../../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: () => ({ + findEntitiesByIds: async (ids) => mockState.entities.filter((e) => ids.map(String).includes(e.id)), + }), +})); +jest.mock('../../user/repositories/user-repository-factory', () => ({ + createUserRepository: () => ({}), +})); +jest.mock('../../user/repositories/organization-membership-repository-factory', () => ({ + createOrganizationMembershipRepository: () => ({}), +})); + +const { createIntegrationRouter } = require('../integration-router'); +const { createApp } = require('../../handlers/app-handler-helpers'); +const { AuthenticateUser } = require('../../user/use-cases/authenticate-user'); +const { User } = require('../../user/user'); +const { ProcessAuthorizationCallback } = require('../../modules/use-cases/process-authorization-callback'); +const { CreateIntegration } = require('../use-cases/create-integration'); +const { GetIntegrationsForUser } = require('../use-cases/get-integrations-for-user'); +const { GetIntegrationInstance } = require('../use-cases/get-integration-instance'); +const { DeleteIntegrationForUser } = require('../use-cases/delete-integration-for-user'); +const { GetEntitiesForUser } = require('../../modules/use-cases/get-entities-for-user'); + +let currentUser; +const caller = ({ individual = { id: '7', organizationId: null }, org = null } = {}) => + new User(individual, org ? { id: String(org) } : null, false, 'individual', true, false); + +async function call(method, path, body) { + const app = createApp((a) => a.use(createIntegrationRouter())); + const server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); + }); + try { + const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { + method, + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : undefined }; + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +const entityRow = (id, userId, organizationId) => ({ + id, + userId: String(userId), + organizationId: organizationId === null ? null : String(organizationId), + moduleName: 'acme', +}); + +describe('v1 routes follow the ownership rules', () => { + beforeEach(() => { + mockState.entities = [entityRow('1', 7, 17), entityRow('2', 7, 18), entityRow('3', 17, 17)]; + mockState.integrations = []; + currentUser = caller({ org: 17 }); + jest.spyOn(AuthenticateUser.prototype, 'execute').mockImplementation(async () => currentUser); + for (const m of ['log', 'info', 'warn', 'error', 'debug']) { + jest.spyOn(console, m).mockImplementation(() => {}); + } + }); + afterEach(() => jest.restoreAllMocks()); + + it('POST /api/authorize: the owner and organization come from the scope, not from user.getId()', async () => { + const execute = jest + .spyOn(ProcessAuthorizationCallback.prototype, 'execute') + .mockResolvedValue({ credential_id: 'c', entity_id: 'e' }); + const res = await call('POST', '/api/authorize', { entityType: 'acme', data: { key: 'k' } }); + expect(res.status).toBe(200); + // The registry says acme is organization-scoped: owned by organization 17, not by person 7. + expect(execute).toHaveBeenCalledWith('17', 'acme', { key: 'k' }, { + scope: 'organization', + organizationId: '17', + legacyOrgId: null, + }); + }); + + it('POST /api/authorize: 400 when the scope needs an organization the request does not have', async () => { + const execute = jest.spyOn(ProcessAuthorizationCallback.prototype, 'execute'); + currentUser = caller(); + const res = await call('POST', '/api/authorize', { entityType: 'acme', data: { key: 'k' } }); + expect(res.status).toBe(400); + expect(execute).not.toHaveBeenCalled(); + }); + + it('GET /api/integrations hands the User, not an id, to both lists', async () => { + const integrations = jest.spyOn(GetIntegrationsForUser.prototype, 'execute').mockResolvedValue([]); + const entities = jest.spyOn(GetEntitiesForUser.prototype, 'execute').mockResolvedValue([]); + const res = await call('GET', '/api/integrations'); + expect(res.status).toBe(200); + expect(integrations.mock.calls[0][0]).toBe(currentUser); + expect(entities.mock.calls[0][0]).toBe(currentUser); + }); + + describe('POST /api/integrations', () => { + it('creates with the resolved owner and the active organization', async () => { + const create = jest.spyOn(CreateIntegration.prototype, 'execute').mockResolvedValue({ id: 'new' }); + const res = await call('POST', '/api/integrations', { entities: ['3'], config: { type: 'sync' } }); + expect(res.status).toBe(201); + // Every entity is the organization's: the organization owns the integration. + expect(create).toHaveBeenCalledWith(['3'], '17', { type: 'sync' }, { + organizationId: '17', + adoptUnscoped: true, // an organization-owned legacy row is the organization's to reuse + }); + }); + + it('refuses an entity of another organization or another person: v1 used to check neither', async () => { + const create = jest.spyOn(CreateIntegration.prototype, 'execute'); + mockState.entities.push(entityRow('4', 8, 17)); + for (const id of ['2', '4']) { + const res = await call('POST', '/api/integrations', { entities: [id], config: { type: 'sync' } }); + expect(res.status).toBe(404); + } + expect(create).not.toHaveBeenCalled(); + }); + }); + + describe('routes on one integration', () => { + beforeEach(() => { + mockState.integrations = [ + { id: 'mine', userId: '7', organizationId: '17', entitiesIds: ['1'], config: { type: 'sync' } }, + { id: 'org', userId: '17', organizationId: '17', entitiesIds: ['3'], config: { type: 'sync' } }, + { id: 'other', userId: '7', organizationId: '18', entitiesIds: ['2'], config: { type: 'sync' } }, + ]; + }); + + it("loads with the record's owner id, so an organization integration works for a member", async () => { + const instance = jest.spyOn(GetIntegrationInstance.prototype, 'execute').mockResolvedValue({ + id: 'org', entities: [], status: 'ENABLED', config: {}, + }); + const res = await call('GET', '/api/integrations/org'); + expect(res.status).toBe(200); + expect(instance).toHaveBeenCalledWith('org', '17'); + }); + + it('404s the same person integration from another organization, and never loads it', async () => { + const instance = jest.spyOn(GetIntegrationInstance.prototype, 'execute'); + const res = await call('GET', '/api/integrations/other'); + expect(res.status).toBe(404); + expect(instance).not.toHaveBeenCalled(); + }); + + it('deletes with the record owner id, so a member can delete the organization integration', async () => { + const remove = jest.spyOn(DeleteIntegrationForUser.prototype, 'execute').mockResolvedValue(); + expect((await call('DELETE', '/api/integrations/org')).status).toBe(204); + expect(remove).toHaveBeenCalledWith('org', '17'); + }); + + it('404s deleting the person integration of another organization, and deletes nothing', async () => { + const remove = jest.spyOn(DeleteIntegrationForUser.prototype, 'execute'); + expect((await call('DELETE', '/api/integrations/other')).status).toBe(404); + expect(remove).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/integrations/tests/use-cases/get-integrations-for-user.test.js b/packages/core/integrations/tests/use-cases/get-integrations-for-user.test.js index 887611a9a..be41b766f 100644 --- a/packages/core/integrations/tests/use-cases/get-integrations-for-user.test.js +++ b/packages/core/integrations/tests/use-cases/get-integrations-for-user.test.js @@ -4,13 +4,13 @@ jest.mock('../../../database/config', () => ({ PRISMA_LOG_LEVEL: 'error,warn', })); -const { GetIntegrationsForUser } = require('../../use-cases/get-integrations-for-user'); +const { GetIntegrationsByUserId } = require('../../use-cases/get-integrations-for-user'); const { TestIntegrationRepository } = require('../doubles/test-integration-repository'); const { TestModuleFactory } = require('../../../modules/tests/doubles/test-module-factory'); const { TestModuleRepository } = require('../../../modules/tests/doubles/test-module-repository'); const { DummyIntegration } = require('../doubles/dummy-integration-class'); -describe('GetIntegrationsForUser Use-Case', () => { +describe('GetIntegrationsByUserId Use-Case (app-level commands, no request)', () => { let integrationRepository; let moduleRepository; let moduleFactory; @@ -20,7 +20,7 @@ describe('GetIntegrationsForUser Use-Case', () => { integrationRepository = new TestIntegrationRepository(); moduleRepository = new TestModuleRepository(); moduleFactory = new TestModuleFactory(); - useCase = new GetIntegrationsForUser({ + useCase = new GetIntegrationsByUserId({ integrationRepository, integrationClasses: [DummyIntegration], moduleFactory, @@ -104,7 +104,7 @@ describe('GetIntegrationsForUser Use-Case', () => { describe('error cases', () => { it('throws error when integration class not found', async () => { - const useCaseWithoutClasses = new GetIntegrationsForUser({ + const useCaseWithoutClasses = new GetIntegrationsByUserId({ integrationRepository, integrationClasses: [], moduleFactory, diff --git a/packages/core/integrations/tests/use-cases/integration-ownership.test.js b/packages/core/integrations/tests/use-cases/integration-ownership.test.js new file mode 100644 index 000000000..5c340039d --- /dev/null +++ b/packages/core/integrations/tests/use-cases/integration-ownership.test.js @@ -0,0 +1,501 @@ +jest.mock('../../../database/config', () => ({ + DB_TYPE: 'mongodb', + getDatabaseType: jest.fn(() => 'mongodb'), + PRISMA_LOG_LEVEL: 'error,warn', +})); + +/** + * Who owns an integration, which entities its creator may bind, and who may + * see and change it (ADR-056 §3, §3.3, §3.6). The scope rules are real; the + * repositories answer the way a database does. + */ +const { User } = require('../../../user/user'); +const { createScopePolicy } = require('../../../modules/scope-policy'); +const { ResolveIntegrationOwnership } = require('../../use-cases/resolve-integration-ownership'); +const { CreateIntegration } = require('../../use-cases/create-integration'); +const { GetIntegrationsForUser } = require('../../use-cases/get-integrations-for-user'); +const { + GetOwnedIntegrationRecord, + CreateIntegrationForUser, +} = require('../../use-cases/integration-operations-for-user'); +const { TestIntegrationRepository } = require('../doubles/test-integration-repository'); +const { TestModuleFactory } = require('../../../modules/tests/doubles/test-module-factory'); +const { DummyIntegration } = require('../doubles/dummy-integration-class'); + +const caller = ({ individual = { id: '7', organizationId: null }, org = null } = {}) => + new User(individual, org ? { id: String(org) } : null, false, 'individual', true, false); + +const entity = (id, userId, organizationId, moduleName = 'crm') => ({ + id, + userId: userId === null ? undefined : String(userId), + organizationId: organizationId === null ? null : String(organizationId), + moduleName, +}); + +const definitionFor = (moduleName) => ({ moduleName, getName: () => moduleName }); +const integrationClass = (Definition) => ({ Definition: { name: 'sync', ...Definition } }); + +function resolver({ entities, integrationClasses, ownership, registry } = {}) { + const moduleRepository = { + findEntitiesByIds: jest.fn(async (ids) => entities.filter((e) => ids.includes(e.id))), + }; + return new ResolveIntegrationOwnership({ + moduleRepository, + integrationClasses: integrationClasses ?? [integrationClass({})], + scopePolicy: createScopePolicy({ ownership, entityTypeRegistry: registry }), + }); +} + +const resolve = (r, ids, user, type = 'sync') => r.execute({ entityIds: ids, config: { type }, user }); + +describe('ResolveIntegrationOwnership: the owner follows the entity scopes', () => { + const entities = [ + entity('mine', 7, 17), // person 7's, connected in 17 + entity('org', 17, 17), // organization 17's + entity('elsewhere', 7, 18), // person 7's, connected in 18 + entity('theirs', 8, 17), // a colleague's + entity('global', null, null), + ]; + + it('all individual: the person, in the active organization', async () => { + expect(await resolve(resolver({ entities }), ['mine'], caller({ org: 17 }))).toMatchObject({ + ownerId: '7', + organizationId: '17', + ownerKind: 'individual', + }); + }); + + it('all organization: the organization user', async () => { + expect(await resolve(resolver({ entities }), ['org'], caller({ org: 17 }))).toMatchObject({ + ownerId: '17', + organizationId: '17', + ownerKind: 'organization', + }); + }); + + it('refuses a mixed integration before anything is written: 400 naming the entity', async () => { + // Derived as the person's, but the integration is loaded by its owner, who must own what it uses. + await expect(resolve(resolver({ entities }), ['org', 'mine'], caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 400 }, + data: { code: 'MIXED_ENTITY_SCOPES', details: { entityId: 'org', scope: 'organization' } }, + }); + await expect(resolve(resolver({ entities }), ['mine', 'org'], caller({ org: 17 }))).rejects.toMatchObject({ + data: { code: 'MIXED_ENTITY_SCOPES' }, + }); + }); + + it('404s a global entity: it is not the tenant\'s to bind', async () => { + await expect(resolve(resolver({ entities }), ['global'], caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 404 }, + data: { code: 'ENTITY_NOT_FOUND' }, + }); + }); + + it('404s an entity that belongs to another organization, a colleague, or nobody, the same as a missing one', async () => { + for (const id of ['elsewhere', 'theirs', 'missing']) { + await expect(resolve(resolver({ entities }), [id], caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 404 }, + data: { code: 'ENTITY_NOT_FOUND' }, + }); + } + }); + + it('without an organization an organization entity is not even visible', async () => { + await expect(resolve(resolver({ entities }), ['org'], caller())).rejects.toMatchObject({ + output: { statusCode: 404 }, + }); + }); + + it('tells the caller whether an unscoped integration is theirs to reuse', async () => { + const noOrg = await resolve(resolver({ entities: [entity('mine', 7, null)] }), ['mine'], caller()); + expect(noOrg.adoptUnscoped).toBe(true); + const other = await resolve(resolver({ entities }), ['mine'], caller({ individual: { id: '7', organizationId: '99' }, org: 17 })); + expect(other.adoptUnscoped).toBe(false); + const legacyOrg = await resolve(resolver({ entities }), ['mine'], caller({ individual: { id: '7', organizationId: '17' }, org: 17 })); + expect(legacyOrg.adoptUnscoped).toBe(true); + }); +}); + +describe('ResolveIntegrationOwnership: what the integration declares', () => { + const entities = [entity('mine', 7, 17), entity('org', 17, 17)]; + const bound = (setting) => + integrationClass({ modules: { crm: { definition: definitionFor('crm'), ...setting } } }); + + it("refuses an entity whose scope the binding does not accept, naming both", async () => { + const r = resolver({ entities, integrationClasses: [bound({ scope: 'organization' })] }); + await expect(resolve(r, ['mine'], caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 400 }, + data: { code: 'ENTITY_SCOPE_MISMATCH', details: { scope: 'individual', allowed: ['organization'] } }, + }); + expect((await resolve(r, ['org'], caller({ org: 17 }))).ownerKind).toBe('organization'); + }); + + it('accepts either scope when the binding allows both', async () => { + const r = resolver({ + entities, + integrationClasses: [bound({ scope: { default: 'individual', allow: ['individual', 'organization'] } })], + }); + await expect(resolve(r, ['mine'], caller({ org: 17 }))).resolves.toBeDefined(); + await expect(resolve(r, ['org'], caller({ org: 17 }))).resolves.toBeDefined(); + }); + + it('reads Definition.entities[key].scope as the binding scope', async () => { + const r = resolver({ + entities, + integrationClasses: [ + integrationClass({ + modules: { crm: { definition: definitionFor('crm') } }, + entities: { crm: { scope: 'organization' } }, + }), + ], + }); + await expect(resolve(r, ['mine'], caller({ org: 17 }))).rejects.toMatchObject({ + data: { code: 'ENTITY_SCOPE_MISMATCH' }, + }); + }); + + it('reads Definition.ownership.scope for every binding that sets none', async () => { + const r = resolver({ + entities, + integrationClasses: [ + integrationClass({ + modules: { crm: { definition: definitionFor('crm') } }, + ownership: { scope: 'individual' }, + }), + ], + }); + await expect(resolve(r, ['org'], caller({ org: 17 }))).rejects.toMatchObject({ + data: { code: 'ENTITY_SCOPE_MISMATCH' }, + }); + }); + + it('does not restrict what an integration may bind by the registry or app level: those decide where a connection is created', async () => { + const registry = new Map([['crm', { settings: { scope: 'organization' } }]]); + const r = resolver({ + entities, + registry, + ownership: { scope: 'organization' }, + integrationClasses: [bound({})], + }); + // The existing individual connection still binds, so adding a registry scope later breaks nothing. + await expect(resolve(r, ['mine'], caller({ org: 17 }))).resolves.toMatchObject({ ownerKind: 'individual' }); + }); + + it("honours an explicit owner: 'organization' and refuses it with an individual entity", async () => { + const r = resolver({ + entities, + integrationClasses: [ + integrationClass({ + modules: { crm: { definition: definitionFor('crm') } }, + ownership: { owner: 'organization' }, + }), + ], + }); + expect((await resolve(r, ['org'], caller({ org: 17 }))).ownerId).toBe('17'); + await expect(resolve(r, ['mine'], caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 400 }, + data: { code: 'ORG_INTEGRATION_INDIVIDUAL_ENTITY' }, + }); + }); + + it("refuses an explicit owner: 'individual' over organization entities, as the owner could not load them", async () => { + const r = resolver({ + entities, + integrationClasses: [ + integrationClass({ + modules: { crm: { definition: definitionFor('crm') } }, + ownership: { owner: 'individual' }, + }), + ], + }); + await expect(resolve(r, ['org'], caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 400 }, + data: { code: 'MIXED_ENTITY_SCOPES' }, + }); + }); +}); + +describe('CreateIntegration reuses a duplicate only within one organization', () => { + let repository; + let create; + beforeEach(() => { + repository = new TestIntegrationRepository(); + create = new CreateIntegration({ + integrationRepository: repository, + integrationClasses: [DummyIntegration], + moduleFactory: new TestModuleFactory(), + }); + }); + const run = (orgId, extra = {}) => + create.execute(['e1'], '7', { type: 'dummy' }, { organizationId: orgId, ...extra }); + + it('stores the organization it is created in', async () => { + await run('17'); + expect([...repository.store.values()][0].organizationId).toBe('17'); + }); + + it('does not hand organization 17 integration back as the one asked for in 18', async () => { + const first = await run('17'); + const second = await run('18'); + expect(second.id).not.toBe(first.id); + expect(repository.store.size).toBe(2); + }); + + it('reuses it in the same organization', async () => { + const first = await run('17'); + const again = await run('17'); + expect(again.id).toBe(first.id); + expect(repository.store.size).toBe(1); + }); + + it('reuses a row written before scoping only where it belongs (adoptUnscoped)', async () => { + const legacy = await repository.createIntegration(['e1'], '7', { type: 'dummy' }); + const refused = await run('17', { adoptUnscoped: false }); + expect(refused.id).not.toBe(legacy.id); + repository.store.delete(refused.id); + const adopted = await run('17', { adoptUnscoped: true }); + expect(adopted.id).toBe(legacy.id); + }); + + it('with no organization, an unscoped row is the duplicate', async () => { + const legacy = await repository.createIntegration(['e1'], '7', { type: 'dummy' }); + expect((await run(null)).id).toBe(legacy.id); + }); +}); + +describe('listing and reaching integrations by organization', () => { + let repository; + let moduleRepository; + const seed = async () => { + repository = new TestIntegrationRepository(); + moduleRepository = { + findEntitiesByIds: jest.fn(async (ids) => ids.map((id) => ({ id }))), + }; + const make = (userId, orgId, name) => + repository.createIntegration(['e1'], userId, { type: 'dummy', name }, { organizationId: orgId }); + return { + mine17: await make('7', '17', 'mine17'), + mine18: await make('7', '18', 'mine18'), + org17: await make('17', '17', 'org17'), + org18: await make('18', '18', 'org18'), + theirs17: await make('8', '17', 'theirs17'), + legacy: await repository.createIntegration(['e1'], '7', { type: 'dummy', name: 'legacy' }), + }; + }; + const lister = (policy) => + new GetIntegrationsForUser({ + integrationRepository: repository, + integrationClasses: [DummyIntegration], + moduleFactory: new TestModuleFactory(), + moduleRepository, + scopePolicy: policy, + }); + const names = (list) => list.map((i) => i.config.name).sort(); + + it("lists the caller's and the active organization's integrations in that organization only", async () => { + await seed(); + expect(names(await lister().execute(caller({ org: 17 })))).toEqual(['mine17', 'org17']); + expect(names(await lister().execute(caller({ org: 18 })))).toEqual(['mine18', 'org18']); + }); + + it('asks for owner/organization pairs, in one query', async () => { + await seed(); + await lister().execute(caller({ org: 17 })); + const asked = repository.operationHistory.filter((op) => op.operation === 'findByOwnership'); + expect(asked).toHaveLength(1); + expect(asked[0].clauses).toEqual([ + { userId: '17', organizationId: '17' }, + { userId: '17', organizationId: null }, + { userId: '7', organizationId: '17' }, + ]); + }); + + it('does not widen the list if a repository returns more than it was asked for', async () => { + await seed(); + repository.findIntegrationsByOwnership = async () => [...repository.store.values()]; + expect(names(await lister().execute(caller({ org: 17 })))).toEqual(['mine17', 'org17']); + }); + + it('carries scope, owner, organizationId and access on each row', async () => { + await seed(); + const list = await lister().execute(caller({ org: 17 })); + const byName = Object.fromEntries(list.map((i) => [i.config.name, i.tenancy])); + expect(byName.mine17).toEqual({ + scope: 'individual', owner: { type: 'individual', id: '7' }, organizationId: '17', access: 'manage', + }); + expect(byName.org17).toEqual({ + scope: 'organization', owner: { type: 'organization', id: '17' }, organizationId: '17', access: 'manage', + }); + }); + + it('legacy rows (no organization) keep showing: with no organization, and in the legacy organization', async () => { + await seed(); + expect(names(await lister().execute(caller()))).toEqual(['legacy']); + expect(names(await lister().execute(caller({ individual: { id: '7', organizationId: '17' }, org: 17 })))).toEqual([ + 'legacy', 'mine17', 'org17', + ]); + // A second organization does not get them. + expect(names(await lister().execute(caller({ individual: { id: '7', organizationId: '17' }, org: 18 })))).toEqual([ + 'mine18', 'org18', + ]); + }); + + it("'all' does not lift an integration out of its organization", async () => { + await seed(); + const policy = createScopePolicy({ ownership: { individualAcross: 'all' } }); + expect(names(await lister(policy).execute(caller({ org: 17 })))).toEqual(['mine17', 'org17']); + }); + + it('filters ?owner and ?scope', async () => { + await seed(); + expect(names(await lister().execute(caller({ org: 17 }), { owner: 'org' }))).toEqual(['org17']); + expect(names(await lister().execute(caller({ org: 17 }), { scope: 'individual' }))).toEqual(['mine17']); + }); + + describe('reaching one', () => { + it('404s another organization integration, a colleague one, and a missing one', async () => { + const rows = await seed(); + const get = new GetOwnedIntegrationRecord({ integrationRepository: repository }); + for (const id of [rows.mine18.id, rows.org18.id, rows.theirs17.id, 'nope']) { + await expect(get.execute(id, caller({ org: 17 }))).rejects.toMatchObject({ output: { statusCode: 404 } }); + } + await expect(get.execute(rows.org17.id, caller({ org: 17 }))).resolves.toMatchObject({ id: rows.org17.id }); + }); + }); +}); + +describe('a missing integration is a 404; other failures are not', () => { + const { integrationNotFoundError, isIntegrationNotFound } = require('../../repositories/integration-not-found'); + const getOwned = (error) => + new GetOwnedIntegrationRecord({ + integrationRepository: { findIntegrationById: jest.fn().mockRejectedValue(error) }, + }); + + it('maps the exact error the repositories throw for a missing id', async () => { + await expect(getOwned(integrationNotFoundError('i9')).execute('i9', caller())).rejects.toMatchObject({ + output: { statusCode: 404 }, + data: { code: 'INTEGRATION_NOT_FOUND' }, + }); + }); + + it('does not turn any other "not found" into a 404: a database fault that mentions it stays a fault', async () => { + for (const message of ['Entity 5 not found', 'Integration with id 5 not found in shard 2', 'connection not found']) { + const error = new Error(message); + await expect(getOwned(error).execute('5', caller())).rejects.toBe(error); + } + }); + + it('recognises only its own message', () => { + expect(isIntegrationNotFound(integrationNotFoundError('x'))).toBe(true); + expect(isIntegrationNotFound(new Error('Integration with id x not found in shard'))).toBe(false); + expect(isIntegrationNotFound(undefined)).toBe(false); + }); +}); + +describe('CreateIntegrationForUser stores the owner and organization the rule decided', () => { + it('creates through the shared create with the resolved owner, organization and adoption flag', async () => { + const entities = [entity('mine', 7, 17)]; + const createIntegration = { execute: jest.fn().mockResolvedValue({ id: 'new' }) }; + const classes = [integrationClass({})]; + const useCase = new CreateIntegrationForUser({ + createIntegration, + resolveIntegrationOwnership: resolver({ entities, integrationClasses: classes }), + integrationClasses: classes, + }); + await useCase.execute({ entities: ['mine'], config: { type: 'sync' } }, caller({ org: 17 })); + expect(createIntegration.execute).toHaveBeenCalledWith(['mine'], '7', { type: 'sync' }, { + organizationId: '17', + adoptUnscoped: false, + }); + }); +}); + +describe('creating twice is idempotent for a legacy organization-owned integration', () => { + // Written before scoping by an organization-primary app: owner = the organization, no organizationId. + const setup = async () => { + const repository = new TestIntegrationRepository(); + const legacy = await repository.createIntegration(['x'], '17', { type: 'dummy' }); + const classes = [DummyIntegration]; + const moduleRepository = { + findEntitiesByIds: jest.fn(async () => [entity('x', 17, null, 'dummy')]), + }; + const useCase = new CreateIntegrationForUser({ + createIntegration: new CreateIntegration({ + integrationRepository: repository, + integrationClasses: classes, + moduleFactory: new TestModuleFactory(), + }), + resolveIntegrationOwnership: new ResolveIntegrationOwnership({ moduleRepository, integrationClasses: classes }), + integrationClasses: classes, + }); + return { repository, legacy, useCase }; + }; + const create = (useCase, user) => + useCase.execute({ entities: ['x'], config: { type: 'dummy' } }, user); + const orgUser = (individual) => new User(individual, { id: '17' }, false, 'organization', false, true); + + it('reuses it when the caller sends only the organization (x-frigg-apporgid)', async () => { + const { repository, legacy, useCase } = await setup(); + const result = await create(useCase, orgUser(null)); + expect(result.id).toBe(legacy.id); + expect(repository.store.size).toBe(1); + expect(repository.operationHistory.filter((op) => op.operation === 'create')).toHaveLength(1); + }); + + it("reuses it when the person's legacy pointer names a different organization", async () => { + const { repository, legacy, useCase } = await setup(); + const result = await create(useCase, orgUser({ id: '7', organizationId: '99' })); + expect(result.id).toBe(legacy.id); + expect(repository.store.size).toBe(1); + }); + + it('reuses it when the pointer names the organization, and twice in a row', async () => { + const { repository, legacy, useCase } = await setup(); + const user = orgUser({ id: '7', organizationId: '17' }); + expect((await create(useCase, user)).id).toBe(legacy.id); + expect((await create(useCase, user)).id).toBe(legacy.id); + expect(repository.store.size).toBe(1); + }); + + it('tells the creator to adopt unscoped rows whenever the owner is the organization', async () => { + const { repository } = await setup(); + const moduleRepository = { findEntitiesByIds: async () => [entity('x', 17, null, 'dummy')] }; + const resolved = await new ResolveIntegrationOwnership({ + moduleRepository, + integrationClasses: [DummyIntegration], + }).execute({ entityIds: ['x'], config: { type: 'dummy' }, user: orgUser({ id: '7', organizationId: '99' }) }); + expect(resolved).toMatchObject({ ownerKind: 'organization', ownerId: '17', adoptUnscoped: true }); + expect(repository.store.size).toBe(1); + }); +}); + +describe('CreateIntegration refuses before it persists: no orphan IN_CREATION rows', () => { + const build = (moduleFactory = new TestModuleFactory()) => { + const repository = new TestIntegrationRepository(); + return { + repository, + create: new CreateIntegration({ + integrationRepository: repository, + integrationClasses: [DummyIntegration], + moduleFactory, + }), + }; + }; + + it('an unknown integration type leaves nothing behind', async () => { + const { repository, create } = build(); + await expect(create.execute(['e1'], '7', { type: 'nope' })).rejects.toThrow(/No integration class found/); + expect(repository.store.size).toBe(0); + }); + + it('an entity its owner cannot load leaves nothing behind', async () => { + const { repository, create } = build({ + getModuleInstance: async (id) => { + throw new Error(`Entity ${id} does not belong to user 7`); + }, + }); + await expect(create.execute(['e1'], '7', { type: 'dummy' })).rejects.toThrow(/does not belong/); + expect(repository.store.size).toBe(0); + expect(repository.operationHistory.some((op) => op.operation === 'create')).toBe(false); + }); +}); diff --git a/packages/core/integrations/use-cases/create-integration.js b/packages/core/integrations/use-cases/create-integration.js index f0c06c106..ff76da974 100644 --- a/packages/core/integrations/use-cases/create-integration.js +++ b/packages/core/integrations/use-cases/create-integration.js @@ -27,13 +27,19 @@ class CreateIntegration { * and entity set, instead of creating a duplicate. * @async * @param {string[]} entities - Array of entity IDs to associate with the integration. - * @param {string} userId - ID of the user creating the integration. + * @param {string} userId - ID of the OWNER of the integration (ADR-056 §3): + * the person or the organization's user, as ResolveIntegrationOwnership decided. * @param {Object} config - Configuration object for the integration. * @param {string} config.type - Type of integration to create. + * @param {{organizationId?: (string|null), adoptUnscoped?: boolean}} [tenancy] + * the organization it is created in (§3.6). A duplicate is only reused in + * the same organization, so one person's integration in organization A is + * not handed back as the one they asked for in B. `adoptUnscoped` also + * accepts a duplicate written before scoping (no organization). * @returns {Promise} The created or reused integration DTO. * @throws {Error} When integration class is not found for the specified type. */ - async execute(entities, userId, config) { + async execute(entities, userId, config, tenancy = {}) { console.log( `[Frigg] Creating ${config?.type} integration for user ${userId} with entities [${entities}]` ); @@ -41,7 +47,8 @@ class CreateIntegration { const existing = await this._findDuplicate( userId, config?.type, - entities + entities, + tenancy ); if (existing) { console.log( @@ -50,11 +57,18 @@ class CreateIntegration { return this._reuseExisting(existing, config); } + // Everything that can be refused is refused before a row is written: an + // unknown type, or an entity the owner cannot load, would otherwise + // leave an IN_CREATION integration behind that nothing moves on. + const integrationClass = this._requireClass(config?.type); + const modules = await this._loadModules(entities, userId); + const integrationRecord = await this.integrationRepository.createIntegration( entities, userId, - config + config, + { organizationId: tenancy.organizationId ?? null } ); console.log( `[Frigg] Created integration ${integrationRecord.id} for user ${userId}` @@ -65,7 +79,8 @@ class CreateIntegration { const survivor = await this._findDuplicate( userId, config?.type, - entities + entities, + tenancy ); if (survivor && String(survivor.id) !== String(integrationRecord.id)) { console.log( @@ -78,7 +93,8 @@ class CreateIntegration { } const integrationInstance = await this._buildInstance( - integrationRecord + integrationRecord, + { integrationClass, modules } ); console.log( `[Frigg] Sending ON_CREATE for integration ${integrationRecord.id}` @@ -135,7 +151,7 @@ class CreateIntegration { return mapIntegrationClassToIntegrationDTO(integrationInstance); } - async _findDuplicate(userId, type, entityIds) { + async _findDuplicate(userId, type, entityIds, tenancy = {}) { const candidates = await this.integrationRepository.findIntegrationsByUserId(userId); const target = [...(entityIds ?? [])].map(String).sort(); @@ -150,6 +166,7 @@ class CreateIntegration { // matching how teardown failures are already handled here. if (integration.status === 'IN_DELETION') return false; if (integration.config?.type !== type) return false; + if (!this._sameOrganization(integration, tenancy)) return false; const current = [...(integration.entitiesIds ?? [])] .map(String) .sort(); @@ -162,6 +179,15 @@ class CreateIntegration { return matches.length ? this._pickOldest(matches) : null; } + /** Same organization, or a row from before scoping that belongs here. */ + _sameOrganization(integration, tenancy) { + const wanted = tenancy.organizationId ?? null; + const has = integration.organizationId ?? null; + if (wanted === null && has === null) return true; + if (wanted !== null && has !== null) return String(wanted) === String(has); + return has === null && tenancy.adoptUnscoped === true; + } + _pickOldest(records) { return [...records].sort((a, b) => { const diff = @@ -173,27 +199,36 @@ class CreateIntegration { })[0]; } - async _buildInstance(integrationRecord) { + _requireClass(type) { const integrationClass = this.integrationClasses.find( - (integrationClass) => - integrationClass.Definition.name === - integrationRecord.config.type + (integrationClass) => integrationClass.Definition.name === type ); - if (!integrationClass) { - throw new Error( - `No integration class found for type: ${integrationRecord.config.type}` - ); + throw new Error(`No integration class found for type: ${type}`); } + return integrationClass; + } + async _loadModules(entityIds, userId) { const modules = []; - for (const entityId of integrationRecord.entitiesIds) { - const moduleInstance = await this.moduleFactory.getModuleInstance( - entityId, - integrationRecord.userId + for (const entityId of entityIds) { + modules.push( + await this.moduleFactory.getModuleInstance(entityId, userId) ); - modules.push(moduleInstance); } + return modules; + } + + async _buildInstance(integrationRecord, preloaded) { + const integrationClass = + preloaded?.integrationClass ?? + this._requireClass(integrationRecord.config.type); + const modules = + preloaded?.modules ?? + (await this._loadModules( + integrationRecord.entitiesIds, + integrationRecord.userId + )); const integrationInstance = new integrationClass({ id: integrationRecord.id, diff --git a/packages/core/integrations/use-cases/get-integrations-for-user.js b/packages/core/integrations/use-cases/get-integrations-for-user.js index 9e9efef81..a62427c9a 100644 --- a/packages/core/integrations/use-cases/get-integrations-for-user.js +++ b/packages/core/integrations/use-cases/get-integrations-for-user.js @@ -2,9 +2,17 @@ const { mapIntegrationClassToIntegrationDTO, } = require('../utils/map-integration-dto'); +const { + describeRecord, + visibilityClauses, + matchesListFilters, +} = require('../../modules/scope-visibility'); +const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy'); /** - * Use case for retrieving all integrations for a specific user. + * Use case for retrieving the integrations a caller may see: the ones they own + * or their active organization owns, created in that organization (ADR-056 + * §3.6), each with `tenancy` ({scope, owner, organizationId, access}). * @class GetIntegrationsForUser */ class GetIntegrationsForUser { @@ -15,13 +23,16 @@ class GetIntegrationsForUser { * @param {Array} params.integrationClasses - Array of available integration classes. * @param {import('../../modules/module-factory').ModuleFactory} params.moduleFactory - Service for module instantiation and management. * @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository - Repository for module and entity data operations. + * @param {Object} [params.scopePolicy] - from createScopePolicy */ constructor({ integrationRepository, integrationClasses, moduleFactory, moduleRepository, + scopePolicy, }) { + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; /** * @type {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} */ @@ -32,57 +43,86 @@ class GetIntegrationsForUser { } /** - * Executes the retrieval of all integrations for a user. * @async - * @param {string} userId - ID of the user whose integrations to retrieve. - * @returns {Promise} Array of integration DTOs for the specified user. + * @param {Object} user - authenticated User + * @param {{scope?: string, owner?: string}} [filters] - `?scope=` and `?owner=` + * @returns {Promise} Array of integration DTOs. */ - async execute(userId) { - const integrationRecords = - await this.integrationRepository.findIntegrationsByUserId(userId); + async execute(user, filters = {}) { + const ctx = this.scopePolicy.accessContext(user); + const records = await this.integrationRepository.findIntegrationsByOwnership( + visibilityClauses(ctx, 'integration') + ); const integrations = []; + for (const record of records) { + // The query is the filter; the same rule again means a repository + // that returns too much cannot widen the list. + const tenancy = describeRecord(record, ctx, 'integration'); + if (!tenancy || !matchesListFilters(tenancy, filters)) continue; + integrations.push({ ...(await this.toDto(record)), tenancy }); + } + return integrations; + } - for (const integrationRecord of integrationRecords) { - const entities = await this.moduleRepository.findEntitiesByIds( - integrationRecord.entitiesIds - ); + /** Hydrates one stored integration record into its DTO. */ + async toDto(integrationRecord) { + const entities = await this.moduleRepository.findEntitiesByIds( + integrationRecord.entitiesIds + ); + + const integrationClass = this.integrationClasses.find( + (integrationClass) => + integrationClass.Definition.name === + integrationRecord.config.type + ); - const integrationClass = this.integrationClasses.find( - (integrationClass) => - integrationClass.Definition.name === - integrationRecord.config.type + const modules = []; + for (const entity of entities) { + const moduleInstance = await this.moduleFactory.getModuleInstance( + entity.id, + integrationRecord.userId ); + modules.push(moduleInstance); + } - const modules = []; - for (const entity of entities) { - const moduleInstance = - await this.moduleFactory.getModuleInstance( - entity.id, - integrationRecord.userId - ); - modules.push(moduleInstance); - } + return mapIntegrationClassToIntegrationDTO({ + id: integrationRecord.id, + userId: integrationRecord.userId, + entities: entities, + config: integrationRecord.config, + status: integrationRecord.status, + version: integrationRecord.version, + messages: integrationRecord.messages || { errors: [], warnings: [] }, + modules, + options: integrationClass.getOptionDetails(), + }); + } +} - const integrationData = { - id: integrationRecord.id, - userId: integrationRecord.userId, - entities: entities, - config: integrationRecord.config, - status: integrationRecord.status, - version: integrationRecord.version, - messages: integrationRecord.messages || { errors: [], warnings: [] }, - modules, - options: integrationClass.getOptionDetails(), - }; +/** + * Every integration one user id owns, with no organization rules: the lookup + * behind the app-level `friggCommands.findIntegrationsByUserId`, which has a + * user id and no request. + */ +class GetIntegrationsByUserId { + /** @param {ConstructorParameters[0]} params */ + constructor(params) { + this.integrationRepository = params.integrationRepository; + this.hydrator = new GetIntegrationsForUser(params); + } - integrations.push( - mapIntegrationClassToIntegrationDTO(integrationData) - ); + /** @param {string} userId */ + async execute(userId) { + const records = await this.integrationRepository.findIntegrationsByUserId( + userId + ); + const integrations = []; + for (const record of records) { + integrations.push(await this.hydrator.toDto(record)); } - return integrations; } } -module.exports = { GetIntegrationsForUser }; +module.exports = { GetIntegrationsForUser, GetIntegrationsByUserId }; diff --git a/packages/core/integrations/use-cases/index.js b/packages/core/integrations/use-cases/index.js index d7ce7a7fc..ec40e18f5 100644 --- a/packages/core/integrations/use-cases/index.js +++ b/packages/core/integrations/use-cases/index.js @@ -1,4 +1,7 @@ -const { GetIntegrationsForUser } = require('./get-integrations-for-user'); +const { + GetIntegrationsForUser, + GetIntegrationsByUserId, +} = require('./get-integrations-for-user'); const { DeleteIntegrationForUser } = require('./delete-integration-for-user'); const { CreateIntegration } = require('./create-integration'); const { GetIntegration } = require('./get-integration'); @@ -9,6 +12,7 @@ const { GetProcess } = require('./get-process'); module.exports = { GetIntegrationsForUser, + GetIntegrationsByUserId, DeleteIntegrationForUser, CreateIntegration, GetIntegration, diff --git a/packages/core/integrations/use-cases/integration-operations-for-user.js b/packages/core/integrations/use-cases/integration-operations-for-user.js index cb7d5acd2..44d5a32e3 100644 --- a/packages/core/integrations/use-cases/integration-operations-for-user.js +++ b/packages/core/integrations/use-cases/integration-operations-for-user.js @@ -1,11 +1,14 @@ const Boom = require('@hapi/boom'); -const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user'); +const { describeRecord } = require('../../modules/scope-visibility'); +const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy'); +const { isIntegrationNotFound } = require('../repositories/integration-not-found'); /** - * Management API v2 integration operations. They put an ownership check - * (missing or foreign integration = 404) in front of the existing - * integration use cases, and pass the record's owner id on, so the - * organisation user model works the same as for entities. + * Management API v2 integration operations. They put an access check + * (missing or unavailable integration = 404) in front of the existing + * integration use cases, and pass the record's owner id on. Access follows + * the owner and the organization the integration was created in (ADR-056 + * §3.6), not the owner alone. */ const notFound = (integrationId) => @@ -13,10 +16,20 @@ const notFound = (integrationId) => code: 'INTEGRATION_NOT_FOUND', }); -/** The integration record, or 404 when it is missing or not the caller's. */ +/** + * The integration record, or 404 when it is missing or the caller may not + * use it: not theirs or their active organization's, or created in another + * organization. + */ class GetOwnedIntegrationRecord { - constructor({ integrationRepository }) { + constructor({ integrationRepository, scopePolicy }) { this.integrationRepository = integrationRepository; + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; + } + + /** `{ scope, owner, organizationId, access }` of a record to this caller, or null. */ + describe(record, user) { + return describeRecord(record, this.scopePolicy.accessContext(user), 'integration'); } async execute(integrationId, user) { @@ -26,12 +39,15 @@ class GetOwnedIntegrationRecord { integrationId ); } catch (error) { - if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) { + if ( + /Invalid ID|ObjectId|Malformed/i.test(error?.message || '') || + isIntegrationNotFound(error) + ) { throw notFound(integrationId); } throw error; } - if (!record || !userOwns(user, record.userId)) { + if (!record || !this.describe(record, user)) { throw notFound(integrationId); } return record; @@ -39,14 +55,15 @@ class GetOwnedIntegrationRecord { } /** - * Creates (or reuses) an integration between entities the caller owns. - * Unlike v1, the entities' ownership and the integration type are checked - * before anything is written. + * Creates (or reuses) an integration between entities the caller may use. + * The entities' access, the binding scopes and the integration type are + * checked before anything is written, and the integration is owned and + * paired with an organization by `resolveIntegrationOwnership` (§3, §3.6). */ class CreateIntegrationForUser { - constructor({ createIntegration, getEntityModuleForUser, integrationClasses }) { + constructor({ createIntegration, resolveIntegrationOwnership, integrationClasses }) { this.createIntegration = createIntegration; - this.getEntityModuleForUser = getEntityModuleForUser; + this.resolveIntegrationOwnership = resolveIntegrationOwnership; this.integrationClasses = integrationClasses || []; } @@ -74,13 +91,17 @@ class CreateIntegrationForUser { details: { field: 'entities' }, }); } - for (const entityId of entities) { - await this.getEntityModuleForUser.loadOwnedEntity(String(entityId), user); - } + const { ownerId, organizationId, adoptUnscoped } = + await this.resolveIntegrationOwnership.execute({ + entityIds: entities, + config, + user, + }); return this.createIntegration.execute( entities.map(String), - user.getId(), - config + ownerId, + config, + { organizationId, adoptUnscoped } ); } } diff --git a/packages/core/integrations/use-cases/integration-operations-for-user.test.js b/packages/core/integrations/use-cases/integration-operations-for-user.test.js index 9930e6340..def630e2e 100644 --- a/packages/core/integrations/use-cases/integration-operations-for-user.test.js +++ b/packages/core/integrations/use-cases/integration-operations-for-user.test.js @@ -1,3 +1,4 @@ +const { ResolveIntegrationOwnership } = require('./resolve-integration-ownership'); const { GetOwnedIntegrationRecord, CreateIntegrationForUser, @@ -48,21 +49,23 @@ describe('v2 integration operations', () => { describe('CreateIntegrationForUser', () => { const build = () => { const createIntegration = { execute: jest.fn().mockResolvedValue({ id: 'new' }) }; - const getEntityModuleForUser = { - loadOwnedEntity: jest.fn(async (id) => { - if (id !== 'e1' && id !== 'e2') { - const Boom = require('@hapi/boom'); - throw Boom.notFound('nope'); - } - return { id }; - }), + // The real ownership rule, over a repository that knows e1 and e2 (the caller's). + const moduleRepository = { + findEntitiesByIds: jest.fn(async (ids) => + ids.filter((id) => id === 'e1' || id === 'e2').map((id) => ({ id, userId: 'u1', moduleName: 'acme' })) + ), }; + const integrationClasses = [{ Definition: { name: 'sync' } }]; return { createIntegration, + moduleRepository, useCase: new CreateIntegrationForUser({ createIntegration, - getEntityModuleForUser, - integrationClasses: [{ Definition: { name: 'sync' } }], + resolveIntegrationOwnership: new ResolveIntegrationOwnership({ + moduleRepository, + integrationClasses, + }), + integrationClasses, }), }; }; @@ -70,7 +73,10 @@ describe('v2 integration operations', () => { it('creates an integration between owned entities', async () => { const { useCase, createIntegration } = build(); await useCase.execute({ entities: ['e1', 'e2'], config: { type: 'sync' } }, user); - expect(createIntegration.execute).toHaveBeenCalledWith(['e1', 'e2'], 'u1', { type: 'sync' }); + expect(createIntegration.execute).toHaveBeenCalledWith( + ['e1', 'e2'], 'u1', { type: 'sync' }, + { organizationId: null, adoptUnscoped: true } + ); }); it('refuses entities the caller does not own', async () => { diff --git a/packages/core/integrations/use-cases/resolve-integration-ownership.js b/packages/core/integrations/use-cases/resolve-integration-ownership.js new file mode 100644 index 000000000..6a9c22518 --- /dev/null +++ b/packages/core/integrations/use-cases/resolve-integration-ownership.js @@ -0,0 +1,177 @@ +const Boom = require('@hapi/boom'); +const { SCOPES, resolveScope } = require('../../modules/scope-resolution'); +const { + integrationOwnerKind, + resolveIntegrationOwner, +} = require('../../modules/scope-resolution'); +const { + describeRecord, + unscopedRowsBelongHere, +} = require('../../modules/scope-visibility'); +const { + bindingScopeSetting, + readIntegrationOwnership, +} = require('../../modules/scope-config'); +const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy'); +const { entityTypeNameOf } = require('../../modules/entity-type-registry'); + +const notFound = (entityId) => + Boom.notFound(`Entity ${entityId} not found`, { code: 'ENTITY_NOT_FOUND' }); + +/** + * Who owns an integration about to be created, and in which organization + * (ADR-056 §3, §3.3, §3.6). Shared by the v1 and v2 create paths. Nothing is + * persisted before this has answered, so a refusal leaves no row behind. + * + * Every entity must be one the caller may use in the active organization. A + * missing one, another organization's and a `global` one are the same 404, so + * ids cannot be probed. When the integration declares a scope (a binding's + * `scope` or `Definition.entities[key].scope`, or `Definition.ownership.scope`) + * the entity's scope must be in it. Levels below the integration (entity type, + * app, default) decide where a connection is CREATED and do not restrict what + * an integration may bind, so an app that adds a registry scope later does not + * break integrations that already use older connections. + * + * The owner follows the entities (all organization: the organization; + * otherwise the person) unless `Definition.ownership.owner` says. Every entity + * must then be of the owner's own scope: an integration is loaded by its owner, + * who must own what it uses, so an integration that mixes an organization + * entity with a person's own, or that names an owner that does not match, is + * refused with a 400 here, before anything is written. + */ +class ResolveIntegrationOwnership { + /** + * @param {Object} params + * @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository + * @param {Array} params.integrationClasses + * @param {Object} [params.scopePolicy] - from createScopePolicy + */ + constructor({ moduleRepository, integrationClasses, scopePolicy }) { + this.moduleRepository = moduleRepository; + this.integrationClasses = integrationClasses || []; + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; + } + + /** + * @param {Object} params + * @param {Array} params.entityIds + * @param {Object} params.config - `{ type }` + * @param {Object} params.user - authenticated User + * @returns {Promise<{ownerId: string, organizationId: (string|null), ownerKind: string, adoptUnscoped: boolean}>} + */ + async execute({ entityIds, config, user }) { + const ctx = this.scopePolicy.accessContext(user); + const integrationClass = this.integrationClasses.find( + (IntegrationClass) => IntegrationClass.Definition?.name === config?.type + ); + const ownership = readIntegrationOwnership(integrationClass); + + const entities = await this.loadEntities(entityIds); + const scoped = entities.map((entity) => ({ + entity, + scope: this.acceptedScope({ entity, ctx, integrationClass, ownership }), + })); + + const ownerKind = integrationOwnerKind( + scoped.map(({ scope }) => scope), + ownership.owner + ); + this.assertOneScope(scoped, ownerKind, config?.type); + return { + ...resolveIntegrationOwner(ownerKind, ctx), + ownerKind, + // Whether an existing integration with no organization (written + // before scoping) is this caller's to reuse. An organization-owned + // one is unambiguous: its owner is the organization, whoever asks + // and whatever their own legacy pointer says. A person's is theirs + // only where scope-visibility says (their legacy organization). + adoptUnscoped: + ownerKind === SCOPES.ORGANIZATION || + unscopedRowsBelongHere(ctx, 'integration'), + }; + } + + /** Every entity must be of the integration owner's own scope (see the class comment). */ + assertOneScope(scoped, ownerKind, type) { + for (const { entity, scope } of scoped) { + if (scope === ownerKind) continue; + // The load-time check (resolveOwnershipConfig) only sees declared + // scopes; this one sees the entities actually named. + if (ownerKind === SCOPES.ORGANIZATION) { + throw Boom.badRequest( + "An organization-owned integration cannot use an 'individual' entity: a shared integration must not run on one person's credential", + { + code: 'ORG_INTEGRATION_INDIVIDUAL_ENTITY', + details: { entityId: String(entity.id) }, + } + ); + } + throw Boom.badRequest( + `Integration '${type}' would be owned by the person but uses the organization entity ${entity.id}. An integration must use entities of one scope: all of the person's, or all of the organization's.`, + { + code: 'MIXED_ENTITY_SCOPES', + details: { entityId: String(entity.id), scope }, + } + ); + } + } + + async loadEntities(entityIds) { + const ids = (entityIds || []).map(String); + if (ids.length === 0) return []; + let found; + try { + found = await this.moduleRepository.findEntitiesByIds(ids); + } catch (error) { + // A malformed id (e.g. not an integer on PostgreSQL) is "not found". + if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) { + throw notFound(ids[0]); + } + throw error; + } + const byId = new Map((found || []).filter(Boolean).map((e) => [String(e.id), e])); + return ids.map((id) => { + if (!byId.has(id)) throw notFound(id); + return byId.get(id); + }); + } + + /** The entity's scope for this caller, or a refusal. */ + acceptedScope({ entity, ctx, integrationClass, ownership }) { + const described = describeRecord(entity, ctx, 'entity'); + if (!described) throw notFound(entity.id); + const { scope } = described; + + const declared = this.declaredScope({ entity, integrationClass, ownership }); + if (declared && !declared.allow.includes(scope)) { + throw Boom.badRequest( + `Entity ${entity.id} is ${scope}-scoped, but this integration binds ${entity.moduleName} at ${declared.allow.join(' or ')} scope`, + { + code: 'ENTITY_SCOPE_MISMATCH', + details: { entityId: String(entity.id), scope, allowed: declared.allow }, + } + ); + } + return scope; + } + + /** + * What the integration itself declares for this entity's binding (levels 2 + * and 3 only), or null when it declares nothing. + */ + declaredScope({ entity, integrationClass, ownership }) { + const Definition = integrationClass?.Definition; + const key = Object.keys(Definition?.modules || {}).find( + (candidate) => + entityTypeNameOf(Definition.modules[candidate]?.definition) === + entity.moduleName + ); + const resolved = resolveScope({ + binding: key === undefined ? undefined : bindingScopeSetting(Definition, key), + integration: ownership.scope, + }); + return resolved.decidedBy === 'default' ? null : resolved; + } +} + +module.exports = { ResolveIntegrationOwnership }; diff --git a/packages/core/management-api/management-api-config.js b/packages/core/management-api/management-api-config.js index fa28951b5..b1d8aeb5f 100644 --- a/packages/core/management-api/management-api-config.js +++ b/packages/core/management-api/management-api-config.js @@ -16,6 +16,9 @@ * }, * } * ``` + * + * TODO(F6): `proxy.scopes` (ADR-056 §3.5) is not read yet; see + * ExecuteEntityProxyRequest. */ // ADR-052 §7. API Gateway HTTP APIs time out at 30s and Lambda responses cap diff --git a/packages/core/management-api/openapi/components.js b/packages/core/management-api/openapi/components.js index 73a98542c..014aa6888 100644 --- a/packages/core/management-api/openapi/components.js +++ b/packages/core/management-api/openapi/components.js @@ -8,6 +8,27 @@ const ref = (name) => ({ $ref: `#/components/schemas/${name}` }); const nullable = (schema) => ({ ...schema, nullable: true }); +// Whose a record is and what the caller may do with it (ADR-056 §3.4). Added to +// entities, credentials and integrations by the list and get use cases. +const tenancyProperties = { + scope: { type: 'string', enum: ['individual', 'organization'], description: 'Whose it is. `global` records are not shown to tenants.' }, + owner: { + type: 'object', + properties: { type: { type: 'string', enum: ['individual', 'organization'] }, id: { type: 'string' } }, + description: "The user that owns it: the person, or the organization's user", + }, + organizationId: nullable({ + type: 'string', + description: + 'The organization it belongs to. Null for an individual entity connected outside an organization, or under ownership.individualAcross "all".', + }), + access: { + type: 'string', + enum: ['manage', 'use', 'view'], + description: 'manage (create, reauthorize, delete), use (bind it, proxy through it) or view (metadata only)', + }, +}; + const errorBody = { type: 'object', required: ['error'], @@ -69,6 +90,7 @@ const SCHEMAS = { config: { type: 'object', additionalProperties: true }, version: nullable({ type: 'string' }), messages: { type: 'object', additionalProperties: true }, + ...tenancyProperties, }, }, ListIntegrationsResponse: { @@ -129,6 +151,7 @@ const SCHEMAS = { credentialId: nullable({ type: 'string' }), userId: nullable({ type: 'string' }), authIsValid: nullable({ type: 'boolean' }), + ...tenancyProperties, }, }, ListEntitiesResponse: { @@ -153,6 +176,16 @@ const SCHEMAS = { isMultiStep: { type: 'boolean' }, stepCount: { type: 'integer', minimum: 1 }, capabilities: {}, + scope: { + type: 'object', + description: + 'The scope a new connection of this type gets and the scopes a request may choose (ADR-056 §3.1). `global` is never offered.', + properties: { + default: { type: 'string', enum: ['individual', 'organization', 'global'] }, + allow: { type: 'array', items: { type: 'string', enum: ['individual', 'organization'] } }, + source: { type: 'string', enum: ['binding', 'integration', 'entityType', 'app', 'default'] }, + }, + }, }, }, ListEntityTypesResponse: { @@ -197,6 +230,12 @@ const SCHEMAS = { data: { type: 'object', additionalProperties: true, description: 'OAuth callback params or step form data' }, step: { type: 'integer', minimum: 1, default: 1 }, sessionId: { type: 'string', description: 'Required for step 2 and later' }, + scope: { + type: 'string', + enum: ['individual', 'organization'], + description: + "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).", + }, }, }, ReauthorizeRequest: { diff --git a/packages/core/management-api/openapi/v2-operations.js b/packages/core/management-api/openapi/v2-operations.js index 7c4bec06a..c1da9ae41 100644 --- a/packages/core/management-api/openapi/v2-operations.js +++ b/packages/core/management-api/openapi/v2-operations.js @@ -34,6 +34,11 @@ const stepQuery = [ query('step', 'Step number, 1-based (default 1)', { type: 'integer', minimum: 1 }), query('sessionId', 'Session from step 1; required for later steps'), ]; +// ADR-056 §3.4: the same two filters on every list that mixes ownership. +const listFilters = [ + query('scope', 'Only records of this scope', { type: 'string', enum: ['individual', 'organization'] }), + query('owner', 'Only the caller\'s own (`me`) or the active organization\'s (`org`)', { type: 'string', enum: ['me', 'org'] }), +]; const anyJson = { description: 'Defined by the integration or module' }; const V2_OPERATIONS = { @@ -41,7 +46,8 @@ const V2_OPERATIONS = { listIntegrations: { summary: "List the caller's integrations", description: - 'Returns only `{ integrations }`. Integration types are at /api/v2/integrations/options and connected accounts at /api/v2/entities.', + '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.', + parameters: listFilters, responses: { 200: ok('Integrations', ref('ListIntegrationsResponse')), ...errors('Unauthorized') }, }, listIntegrationOptions: { @@ -108,6 +114,9 @@ const V2_OPERATIONS = { // Entities listEntities: { summary: "List the caller's connected entities", + description: + "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`.", + parameters: listFilters, responses: { 200: ok('Entities', ref('ListEntitiesResponse')), ...errors('Unauthorized') }, }, listEntityTypes: { diff --git a/packages/core/management-api/v2/create-v2-router.test.js b/packages/core/management-api/v2/create-v2-router.test.js index f28a6a9c0..337594b85 100644 --- a/packages/core/management-api/v2/create-v2-router.test.js +++ b/packages/core/management-api/v2/create-v2-router.test.js @@ -43,7 +43,13 @@ describe('Management API v2 router: integrations and entities', () => { expect(res.body.integrations[0]).toEqual({ id: 'i1', entities: ['e1'], status: 'ENABLED', config: { type: 'sync' }, version: null, messages: {}, }); - expect(deps.getIntegrationsForUser.execute).toHaveBeenCalledWith('user-1'); + // The use case gets the User, not an id: it needs the active + // organization too (ADR-056 §3.6). No ?scope / ?owner filter was sent. + expect(deps.getIntegrationsForUser.execute).toHaveBeenCalledWith( + expect.objectContaining({ getId: expect.any(Function) }), + { scope: undefined, owner: undefined } + ); + expect(deps.getIntegrationsForUser.execute.mock.calls[0][0].getId()).toBe('user-1'); }); it('lists the integration types this app offers', async () => { diff --git a/packages/core/management-api/v2/dependencies.js b/packages/core/management-api/v2/dependencies.js index 14450486a..c87cb6a89 100644 --- a/packages/core/management-api/v2/dependencies.js +++ b/packages/core/management-api/v2/dependencies.js @@ -29,6 +29,13 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { getEntityTypeRegistry, getEntityTypeDefinitions, } = require('../../modules/entity-type-registry'); + const { createScopePolicy } = require('../../modules/scope-policy'); + const { + AuthorizeEntityForUser, + } = require('../../modules/use-cases/authorize-entity-for-user'); + const { + ResolveIntegrationOwnership, + } = require('../../integrations/use-cases/resolve-integration-ownership'); const { AuthenticateWithSharedSecret, } = require('../../user/use-cases/authenticate-with-shared-secret'); @@ -85,6 +92,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { appDefinition.entityTypeRegistry ?? getEntityTypeRegistry(appDefinition); const moduleDefinitions = getEntityTypeDefinitions(registry); + // Ownership by scope (ADR-056 §3): one object every use case asks. + const scopePolicy = createScopePolicy({ + ownership: appDefinition.ownership, + entityTypeRegistry: registry, + }); const moduleRepository = createModuleRepository(); const integrationRepository = createIntegrationRepository(); @@ -107,6 +119,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { const getEntityModuleForUser = new GetEntityModuleForUser({ moduleRepository, moduleDefinitions, + scopePolicy, }); const getCredentialForUser = new credentialOps.GetCredentialForUserV2({ credentialRepository, @@ -124,17 +137,22 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { moduleDefinitions, getAuthorizationRequirements, authorizationSessionRepository, - processAuthorizationCallback: new ProcessAuthorizationCallback({ - moduleRepository, - credentialRepository, - integrationRepository, - moduleDefinitions, + authorizeEntityForUser: new AuthorizeEntityForUser({ + scopePolicy, + processAuthorizationCallback: new ProcessAuthorizationCallback({ + moduleRepository, + credentialRepository, + integrationRepository, + moduleDefinitions, + }), }), moduleRepository, credentialRepository, + scopePolicy, }); const getOwnedIntegrationRecord = new integrationOps.GetOwnedIntegrationRecord({ integrationRepository, + scopePolicy, }); const getIntegrationInstanceForUser = new integrationOps.GetIntegrationInstanceForUser({ getOwnedIntegrationRecord, @@ -144,6 +162,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { return { config, moduleDefinitions, + scopePolicy, repositories: { moduleRepository, integrationRepository, @@ -159,6 +178,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { integrationClasses, moduleFactory, moduleRepository, + scopePolicy, }), getPossibleIntegrations: new GetPossibleIntegrations({ integrationClasses }), createIntegrationForUser: new integrationOps.CreateIntegrationForUser({ @@ -167,7 +187,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { integrationClasses, moduleFactory, }), - getEntityModuleForUser, + resolveIntegrationOwnership: new ResolveIntegrationOwnership({ + moduleRepository, + integrationClasses, + scopePolicy, + }), integrationClasses, }), getIntegrationInstanceForUser, @@ -199,7 +223,10 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { // Entities getEntityModuleForUser, - listEntitiesForUser: new entityOps.ListEntitiesForUser({ moduleRepository }), + listEntitiesForUser: new entityOps.ListEntitiesForUser({ + moduleRepository, + scopePolicy, + }), getEntityForUser: new entityOps.GetEntityForUser({ getEntityModuleForUser }), deleteEntityForUser: new entityOps.DeleteEntityForUser({ getEntityModuleForUser, @@ -211,8 +238,8 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) { refreshEntityOptionsForUser: new entityOps.RefreshEntityOptionsForUser({ getEntityModuleForUser, }), - listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry }), - getEntityType: new GetEntityType({ entityTypeRegistry: registry }), + listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry, scopePolicy }), + getEntityType: new GetEntityType({ entityTypeRegistry: registry, scopePolicy }), getAuthorizationRequirements, // Credentials diff --git a/packages/core/management-api/v2/dto.js b/packages/core/management-api/v2/dto.js index 750184890..f2b2c9a35 100644 --- a/packages/core/management-api/v2/dto.js +++ b/packages/core/management-api/v2/dto.js @@ -13,6 +13,20 @@ const idOf = (value) => { return String(value); }; +/** + * `{ scope, owner, organizationId, access }` (ADR-056 §3.4): whose a record is + * and what the caller may do with it. Empty when the use case did not say. + */ +function tenancyFields(tenancy) { + if (!tenancy) return {}; + return { + scope: tenancy.scope, + owner: tenancy.owner, + organizationId: tenancy.organizationId ?? null, + access: tenancy.access, + }; +} + /** An entity (connected account). */ function toEntityDto(entity) { const credential = @@ -30,6 +44,7 @@ function toEntityDto(entity) { typeof credential?.authIsValid === 'boolean' ? credential.authIsValid : null, + ...tenancyFields(entity.tenancy), }; } @@ -42,6 +57,7 @@ function toIntegrationDto(integration) { config: integration.config ?? {}, version: integration.version ?? null, messages: integration.messages ?? {}, + ...tenancyFields(integration.tenancy), }; } @@ -115,6 +131,7 @@ function maskCredential(credential) { } module.exports = { + tenancyFields, toEntityDto, toIntegrationDto, maskCredential, diff --git a/packages/core/management-api/v2/entity-scopes.test.js b/packages/core/management-api/v2/entity-scopes.test.js new file mode 100644 index 000000000..9fd527317 --- /dev/null +++ b/packages/core/management-api/v2/entity-scopes.test.js @@ -0,0 +1,445 @@ +/** + * ADR-056 §3 at the v2 API, through the real router, dependencies and use + * cases. The repositories are the only fakes, and they answer the way a + * database does: they run the owner/organization clauses they are handed, so + * a use case that asked for the wrong rows would get the wrong rows back. + * Module is faked because building a real API client is not what is tested. + */ +jest.mock('../../modules/module', () => ({ + Module: jest.fn().mockImplementation(({ userId, definition, entity }) => ({ + userId, + entity, + credential: undefined, + definition, + apiClass: { requesterType: 'apiKey' }, + api: { delegate: null }, + testAuth: jest.fn().mockResolvedValue(true), + apiParamsFromCredential: jest.fn().mockReturnValue({}), + apiParamsFromEntity: jest.fn().mockReturnValue({}), + getName: jest.fn().mockReturnValue('acme'), + })), +})); + +const mockState = { entities: [], credentials: [], integrations: [], nextId: 100 }; +const mockSame = (a, b) => + a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b); + +jest.mock('../../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: () => ({ + findEntitiesByOwnership: async (clauses) => + mockState.entities.filter((row) => require('../../modules/tests/doubles/ownership-matching').matchesAnyClause(row, clauses)), + findEntityById: async (id) => { + const row = mockState.entities.find((e) => String(e.id) === String(id)); + if (!row) throw new Error(`Entity ${id} not found`); + return row; + }, + findEntitiesByIds: async (ids) => + mockState.entities.filter((e) => ids.map(String).includes(String(e.id))), + findEntitiesByUserIdAndModuleName: async (userId, moduleName) => + mockState.entities.filter((e) => mockSame(e.userId, userId) && e.moduleName === moduleName), + findEntities: async (filter) => + mockState.entities.filter( + (e) => + (filter.credentialId === undefined || + String(e.credential?.id) === String(filter.credentialId)) && + (filter.user === undefined || mockSame(e.userId, filter.user)) && + (filter.moduleName === undefined || e.moduleName === filter.moduleName) && + (filter.externalId === undefined || e.externalId === filter.externalId) + ), + findEntity: async () => null, + createEntity: async (data, tenancy = {}) => { + const { user, credential, ...rest } = data; + const row = { + ...rest, + id: String(mockState.nextId++), + userId: String(user), + credential: credential ? { id: String(credential) } : null, + organizationId: tenancy.organizationId ?? null, + }; + mockState.entities.push(row); + return row; + }, + updateEntity: async () => null, + unsetCredential: async () => true, + deleteEntity: async (id) => { + mockState.entities = mockState.entities.filter((e) => String(e.id) !== String(id)); + return true; + }, + }), +})); +jest.mock('../../credential/repositories/credential-repository-factory', () => ({ + createCredentialRepository: () => ({ + findCredentialsByUserId: async (userId) => + mockState.credentials.filter((c) => mockSame(c.userId, userId)), + findCredentialById: async (id) => + mockState.credentials.find((c) => String(c.id) === String(id)) ?? null, + upsertCredential: async ({ identifiers, details }) => { + const row = { + id: `c${mockState.nextId++}`, + userId: String(identifiers.user), + externalId: identifiers.externalId, + ...details, + }; + mockState.credentials.push(row); + return row; + }, + deleteCredentialById: async () => true, + }), +})); +jest.mock('../../integrations/repositories/integration-repository-factory', () => ({ + createIntegrationRepository: () => ({ + findIntegrationsByOwnership: async (clauses) => + mockState.integrations.filter((row) => require('../../modules/tests/doubles/ownership-matching').matchesAnyClause(row, clauses)), + findIntegrationById: async (id) => { + const row = mockState.integrations.find((i) => String(i.id) === String(id)); + if (!row) throw new Error(`Integration with id ${id} not found`); + return row; + }, + findIntegrationsByEntityId: async () => [], + findIntegrationsByUserId: async (userId) => + mockState.integrations.filter((i) => mockSame(i.userId, userId)), + createIntegration: async (entities, userId, config, tenancy = {}) => { + const row = { + id: `i${mockState.nextId++}`, + entitiesIds: entities, + userId: String(userId), + organizationId: tenancy.organizationId ?? null, + config, + version: '0.0.0', + status: 'IN_CREATION', + messages: {}, + createdAt: new Date(), + }; + mockState.integrations.push(row); + return row; + }, + patchIntegrationConfig: async (id) => mockState.integrations.find((i) => i.id === id), + updateIntegrationStatus: async () => true, + }), +})); +jest.mock('../../user/repositories/user-repository-factory', () => ({ + createUserRepository: () => ({}), +})); +jest.mock('../../user/repositories/organization-membership-repository-factory', () => ({ + createOrganizationMembershipRepository: () => ({}), +})); +jest.mock('../../modules/repositories/authorization-session-repository-factory', () => ({ + createAuthorizationSessionRepository: () => ({}), +})); + +const { createApp } = require('../../handlers/app-handler-helpers'); +const { request } = require('../test-support/http-client'); +const { AuthenticateUser } = require('../../user/use-cases/authenticate-user'); +const { User } = require('../../user/user'); +const { resolveManagementApiConfig } = require('../management-api-config'); +const { createManagementApiV2Router } = require('./create-v2-router'); +const { getEntityTypeRegistry } = require('../../modules/entity-type-registry'); +const { resolveOwnershipConfig } = require('../../modules/entity-scope'); + +const AUTH = { authorization: 'Bearer token' }; + +const moduleDefinition = (moduleName, extra = {}) => ({ + moduleName, + getName: () => moduleName, + modelName: moduleName, + API: { requesterType: 'apiKey' }, + requiredAuthMethods: { + setAuthParams: async () => ({}), + getCredentialDetails: async (_api, userId) => ({ + identifiers: { user: userId, externalId: `cred-${userId}` }, + details: { api_key: 'sk_live_0123456789abcdef' }, + }), + getEntityDetails: async (_api, _params, _token, userId) => ({ + identifiers: { user: userId, externalId: 'acct-1' }, + details: { name: 'Acme account' }, + }), + }, + getAuthRequirementsForStep: async () => ({ + type: 'api-key', + data: { jsonSchema: { type: 'object', properties: { key: { type: 'string' } } } }, + }), + ...extra, +}); + +const acme = moduleDefinition('acme'); +const slack = moduleDefinition('slack'); +const { IntegrationBase } = require('../../integrations/integration-base'); + +class SyncIntegration extends IntegrationBase { + static Definition = { + name: 'sync', + version: '1.0.0', + modules: { acme: { definition: acme } }, + display: { + label: 'Sync', + description: 'Sync', + detailsUrl: 'https://example.com', + icon: 'sync', + }, + }; + static getOptionDetails() { + return { name: 'sync' }; + } + // The lifecycle is not under test: ownership is decided before it runs. + async initialize() {} + async send() { + return null; + } +} +const syncIntegration = SyncIntegration; + +let currentUser; +const caller = ({ individual = { id: '7', organizationId: null }, org = null } = {}) => + new User(individual, org ? { id: String(org) } : null, false, 'individual', true, false); + +function appFor(appDefinition) { + const entityTypeRegistry = getEntityTypeRegistry(appDefinition); + const definition = { + ...appDefinition, + entityTypeRegistry, + ownership: resolveOwnershipConfig(appDefinition, entityTypeRegistry), + }; + const config = resolveManagementApiConfig(definition.managementApi); + return createApp((app) => app.use(createManagementApiV2Router({ appDefinition: definition, config }))); +} + +const entityRow = (id, userId, organizationId, extra = {}) => ({ + id, + userId: String(userId), + organizationId: organizationId === null ? null : String(organizationId), + moduleName: 'acme', + name: `entity ${id}`, + credential: extra.credentialId ? { id: extra.credentialId, authIsValid: true } : null, +}); + +describe('ownership by scope at the v2 API (ADR-056 §3)', () => { + const previousDbType = process.env.DB_TYPE; + beforeAll(() => { + process.env.DB_TYPE = 'postgresql'; + }); + afterAll(() => { + if (previousDbType === undefined) delete process.env.DB_TYPE; + else process.env.DB_TYPE = previousDbType; + }); + beforeEach(() => { + mockState.entities = []; + mockState.credentials = []; + mockState.integrations = []; + mockState.nextId = 100; + currentUser = caller({ org: 17 }); + jest.spyOn(AuthenticateUser.prototype, 'execute').mockImplementation(async () => currentUser); + }); + afterEach(() => jest.restoreAllMocks()); + + const app = (extra = {}) => + appFor({ + integrations: [syncIntegration], + entities: { slack: { definition: slack, scope: 'organization' } }, + ...extra, + }); + + describe('authorize', () => { + it('creates an individual entity owned by the person, in the active organization', async () => { + const res = await request(app(), { + method: 'POST', + path: '/api/v2/authorize', + headers: AUTH, + body: { entityType: 'acme', data: { key: 'k' } }, + }); + expect(res.status).toBe(200); + expect(res.body.entity).toMatchObject({ + scope: 'individual', + owner: { type: 'individual', id: '7' }, + organizationId: '17', + access: 'manage', + }); + expect(mockState.entities[0]).toMatchObject({ userId: '7', organizationId: '17' }); + expect(mockState.credentials[0].userId).toBe('7'); + }); + + it('creates an organization entity owned by the organization when the registry says so', async () => { + const res = await request(app(), { + method: 'POST', + path: '/api/v2/authorize', + headers: AUTH, + body: { entityType: 'slack', data: { key: 'k' } }, + }); + expect(res.status).toBe(200); + expect(res.body.entity).toMatchObject({ + scope: 'organization', + owner: { type: 'organization', id: '17' }, + organizationId: '17', + }); + expect(mockState.entities[0]).toMatchObject({ userId: '17', organizationId: '17' }); + }); + + it('lets the request choose within the allow list and refuses outside it', async () => { + const flexible = app({ + entities: { + slack: { definition: slack, scope: { default: 'organization', allow: ['organization', 'individual'] } }, + }, + }); + const asIndividual = await request(flexible, { + method: 'POST', path: '/api/v2/authorize', headers: AUTH, + body: { entityType: 'slack', scope: 'individual', data: { key: 'k' } }, + }); + expect(asIndividual.body.entity).toMatchObject({ scope: 'individual', owner: { id: '7' } }); + + const refused = await request(app(), { + method: 'POST', path: '/api/v2/authorize', headers: AUTH, + body: { entityType: 'slack', scope: 'individual', data: { key: 'k' } }, + }); + expect(refused.status).toBe(403); + expect(refused.body.error.code).toBe('SCOPE_NOT_ALLOWED'); + }); + + it('refuses global with 403 and a clear message, and creates nothing', async () => { + const res = await request(app(), { + method: 'POST', path: '/api/v2/authorize', headers: AUTH, + body: { entityType: 'acme', scope: 'global', data: { key: 'k' } }, + }); + expect(res.status).toBe(403); + expect(res.body.error.message).toMatch(/app owner through admin routes/); + expect(mockState.entities).toHaveLength(0); + }); + + it('400s an organization entity with no active organization, naming the header', async () => { + currentUser = caller(); + const res = await request(app(), { + method: 'POST', path: '/api/v2/authorize', headers: AUTH, + body: { entityType: 'slack', data: { key: 'k' } }, + }); + expect(res.status).toBe(400); + expect(res.body.error.details.field).toBe('x-frigg-apporgid'); + }); + + it("under individualAcross 'all' stores the entity with no organization", async () => { + const res = await request(app({ ownership: { individualAcross: 'all' } }), { + method: 'POST', path: '/api/v2/authorize', headers: AUTH, + body: { entityType: 'acme', data: { key: 'k' } }, + }); + expect(res.body.entity).toMatchObject({ scope: 'individual', organizationId: null }); + expect(mockState.entities[0].organizationId).toBeNull(); + }); + }); + + describe('lists', () => { + beforeEach(() => { + mockState.entities = [ + entityRow('1', 7, 17, { credentialId: 'c1' }), + entityRow('2', 7, 18, { credentialId: 'c1' }), + entityRow('3', 17, 17, { credentialId: 'c17' }), + entityRow('4', 8, 17), + entityRow('5', 18, 18), + ]; + mockState.credentials = [ + { id: 'c1', userId: '7', authIsValid: true, access_token: 'sk_live_0123456789abcdef' }, + { id: 'c17', userId: '17', authIsValid: true, api_key: 'sk_live_0123456789abcdef' }, + ]; + }); + const ids = (list) => list.map((e) => e.id).sort(); + + it('lists entities per active organization with scope, owner, organizationId and access', async () => { + const res = await request(app(), { path: '/api/v2/entities', headers: AUTH }); + expect(res.status).toBe(200); + expect(ids(res.body.entities)).toEqual(['1', '3']); + expect(res.body.entities.find((e) => e.id === '3')).toMatchObject({ + scope: 'organization', + owner: { type: 'organization', id: '17' }, + organizationId: '17', + access: 'manage', + }); + currentUser = caller({ org: 18 }); + const other = await request(app(), { path: '/api/v2/entities', headers: AUTH }); + expect(ids(other.body.entities)).toEqual(['2', '5']); + }); + + it('filters by ?scope and ?owner, and 400s a value it does not know', async () => { + const byScope = await request(app(), { path: '/api/v2/entities?scope=organization', headers: AUTH }); + expect(ids(byScope.body.entities)).toEqual(['3']); + const byOwner = await request(app(), { path: '/api/v2/entities?owner=me', headers: AUTH }); + expect(ids(byOwner.body.entities)).toEqual(['1']); + const bad = await request(app(), { path: '/api/v2/entities?scope=global', headers: AUTH }); + expect(bad.status).toBe(400); + }); + + it('gets one entity in its organization and 404s it from another', async () => { + const ok = await request(app(), { path: '/api/v2/entities/1', headers: AUTH }); + expect(ok.status).toBe(200); + expect(ok.body).toMatchObject({ scope: 'individual', organizationId: '17' }); + currentUser = caller({ org: 18 }); + const elsewhere = await request(app(), { path: '/api/v2/entities/1', headers: AUTH }); + expect(elsewhere.status).toBe(404); + }); + + it('lists integrations per active organization with the same fields', async () => { + mockState.integrations = [ + { id: 'i1', userId: '7', organizationId: '17', entitiesIds: ['1'], config: { type: 'sync' }, status: 'ENABLED' }, + { id: 'i2', userId: '7', organizationId: '18', entitiesIds: ['2'], config: { type: 'sync' }, status: 'ENABLED' }, + { id: 'i3', userId: '17', organizationId: '17', entitiesIds: ['3'], config: { type: 'sync' }, status: 'ENABLED' }, + ]; + const res = await request(app(), { path: '/api/v2/integrations', headers: AUTH }); + expect(res.status).toBe(200); + expect(res.body.integrations.map((i) => [i.id, i.scope, i.organizationId, i.access]).sort()).toEqual([ + ['i1', 'individual', '17', 'manage'], + ['i3', 'organization', '17', 'manage'], + ]); + const filtered = await request(app(), { path: '/api/v2/integrations?owner=org', headers: AUTH }); + expect(filtered.body.integrations.map((i) => i.id)).toEqual(['i3']); + }); + }); + + describe('integrations', () => { + beforeEach(() => { + mockState.entities = [ + entityRow('1', 7, 17, { credentialId: 'c1' }), + entityRow('2', 7, 18, { credentialId: 'c1' }), + entityRow('3', 8, 17, { credentialId: 'c8' }), + ]; + }); + const create = (entities, extra = {}) => + request(app(), { + method: 'POST', path: '/api/v2/integrations', headers: AUTH, + body: { entities, config: { type: 'sync' }, ...extra }, + }); + + it('creates one owned by the person and paired with the active organization', async () => { + const res = await create(['1']); + expect(res.status).toBe(201); + expect(mockState.integrations).toHaveLength(1); + expect(mockState.integrations[0]).toMatchObject({ userId: '7', organizationId: '17' }); + }); + + it('refuses an entity connected in another organization, and a colleague one, with 404 and no write', async () => { + for (const entities of [['2'], ['3']]) { + const res = await create(entities); + expect(res.status).toBe(404); + } + expect(mockState.integrations).toHaveLength(0); + }); + + it('does not reuse an integration from another organization', async () => { + await create(['1']); + currentUser = caller({ org: 18 }); + const res = await create(['2']); + expect(res.status).toBe(201); + expect(mockState.integrations.map((i) => i.organizationId).sort()).toEqual(['17', '18']); + }); + }); + + describe('entity types', () => { + it('say which scope a connection gets and which a request may choose', async () => { + const res = await request( + app({ + entities: { + slack: { definition: slack, scope: { default: 'organization', allow: ['organization', 'individual'] } }, + }, + }), + { path: '/api/v2/entities/types', headers: AUTH } + ); + const byType = Object.fromEntries(res.body.types.map((t) => [t.type, t.scope])); + expect(byType.slack).toEqual({ default: 'organization', allow: ['organization', 'individual'], source: 'entityType' }); + expect(byType.acme).toEqual({ default: 'individual', allow: ['individual'], source: 'default' }); + }); + }); +}); diff --git a/packages/core/management-api/v2/handlers/authorization.js b/packages/core/management-api/v2/handlers/authorization.js index 800d74c9a..68dd651fb 100644 --- a/packages/core/management-api/v2/handlers/authorization.js +++ b/packages/core/management-api/v2/handlers/authorization.js @@ -63,6 +63,7 @@ function createAuthorizationHandlers(useCases) { data: body.data, step, sessionId, + scope: optionalString(body.scope, 'scope'), }); res.json(toAuthorizationResponse(result)); }, diff --git a/packages/core/management-api/v2/handlers/entities.js b/packages/core/management-api/v2/handlers/entities.js index 5e033d269..8a3029670 100644 --- a/packages/core/management-api/v2/handlers/entities.js +++ b/packages/core/management-api/v2/handlers/entities.js @@ -1,5 +1,5 @@ const { toEntityDto } = require('../dto'); -const { objectBody, parseStep } = require('../request'); +const { objectBody, parseStep, parseListFilters } = require('../request'); /** Management API v2 entity and entity-type handlers, keyed by operationId. */ function createEntityHandlers(useCases) { @@ -16,17 +16,20 @@ function createEntityHandlers(useCases) { } = useCases; return { - async listEntities(_req, res, user) { - const entities = await listEntitiesForUser.execute(user); + async listEntities(req, res, user) { + const entities = await listEntitiesForUser.execute( + user, + parseListFilters(req.query) + ); res.json({ entities: entities.map(toEntityDto) }); }, - async listEntityTypes(_req, res) { - res.json({ types: await listEntityTypes.execute() }); + async listEntityTypes(_req, res, user) { + res.json({ types: await listEntityTypes.execute({ user }) }); }, - async getEntityType(req, res) { - res.json(await getEntityType.execute(req.params.entityType)); + async getEntityType(req, res, user) { + res.json(await getEntityType.execute(req.params.entityType, { user })); }, // Describes a step's requirements without starting an authorization diff --git a/packages/core/management-api/v2/handlers/integrations.js b/packages/core/management-api/v2/handlers/integrations.js index 134943950..f8201316e 100644 --- a/packages/core/management-api/v2/handlers/integrations.js +++ b/packages/core/management-api/v2/handlers/integrations.js @@ -1,5 +1,5 @@ const { toIntegrationDto } = require('../dto'); -const { objectBody, optionalString } = require('../request'); +const { objectBody, optionalString, parseListFilters } = require('../request'); /** * Management API v2 integration handlers, keyed by registry operationId. @@ -27,8 +27,11 @@ function createIntegrationHandlers(useCases) { ); return { - async listIntegrations(_req, res, user) { - const integrations = await getIntegrationsForUser.execute(user.getId()); + async listIntegrations(req, res, user) { + const integrations = await getIntegrationsForUser.execute( + user, + parseListFilters(req.query) + ); res.json({ integrations: integrations.map(toIntegrationDto) }); }, diff --git a/packages/core/management-api/v2/request.js b/packages/core/management-api/v2/request.js index 8131594a6..825543549 100644 --- a/packages/core/management-api/v2/request.js +++ b/packages/core/management-api/v2/request.js @@ -1,4 +1,5 @@ const Boom = require('@hapi/boom'); +const { LIST_OWNERS } = require('../../modules/scope-visibility'); /** The JSON body as an object, or a 400 VALIDATION_ERROR. */ function objectBody(req) { @@ -51,4 +52,34 @@ function optionalString(value, name) { return value; } -module.exports = { objectBody, requireFields, parseStep, optionalString }; +const LIST_SCOPES = ['individual', 'organization']; + +/** + * `?scope=individual|organization` and `?owner=me|org` on a list (ADR-056 + * §3.4). Anything else is a 400. + */ +function parseListFilters(query = {}) { + const scope = optionalString(query.scope, 'scope'); + const owner = optionalString(query.owner, 'owner'); + if (scope !== undefined && !LIST_SCOPES.includes(scope)) { + throw Boom.badRequest(`scope must be one of ${LIST_SCOPES.join(', ')}`, { + code: 'VALIDATION_ERROR', + details: { field: 'scope' }, + }); + } + if (owner !== undefined && !Object.hasOwn(LIST_OWNERS, owner)) { + throw Boom.badRequest('owner must be me or org', { + code: 'VALIDATION_ERROR', + details: { field: 'owner' }, + }); + } + return { scope, owner }; +} + +module.exports = { + objectBody, + requireFields, + parseStep, + optionalString, + parseListFilters, +}; diff --git a/packages/core/management-api/v2/request.test.js b/packages/core/management-api/v2/request.test.js new file mode 100644 index 000000000..7593e8391 --- /dev/null +++ b/packages/core/management-api/v2/request.test.js @@ -0,0 +1,24 @@ +const { parseListFilters } = require('./request'); + +describe('parseListFilters (ADR-056 §3.4)', () => { + it('parses ?scope and ?owner', () => { + expect(parseListFilters({})).toEqual({ scope: undefined, owner: undefined }); + expect(parseListFilters({ scope: 'individual', owner: 'org' })).toEqual({ + scope: 'individual', + owner: 'org', + }); + expect(parseListFilters({ scope: '', owner: '' })).toEqual({ scope: undefined, owner: undefined }); + }); + + it.each([[{ scope: 'global' }], [{ scope: 'team' }], [{ owner: 'everyone' }], [{ scope: ['a', 'b'] }], [{ owner: 'constructor' }]])( + 'rejects %j with 400', + (query) => { + expect.assertions(1); + try { + parseListFilters(query); + } catch (error) { + expect(error.output.statusCode).toBe(400); + } + } + ); +}); diff --git a/packages/core/modules/entity-scope.js b/packages/core/modules/entity-scope.js new file mode 100644 index 000000000..31746cabb --- /dev/null +++ b/packages/core/modules/entity-scope.js @@ -0,0 +1,15 @@ +/** + * Entity scopes and ownership (ADR-056 §3), re-exported from the four files it + * is split into: + * + * scope-resolution constants, scope resolution, ownership on create + * scope-config reading and validating app-definition settings + * scope-visibility who may see which stored row + * scope-policy the object a use case asks + */ +module.exports = { + ...require('./scope-resolution'), + ...require('./scope-config'), + ...require('./scope-visibility'), + ...require('./scope-policy'), +}; diff --git a/packages/core/modules/entity-scope.test.js b/packages/core/modules/entity-scope.test.js new file mode 100644 index 000000000..ad1be7c76 --- /dev/null +++ b/packages/core/modules/entity-scope.test.js @@ -0,0 +1,698 @@ +const { + parseScopeSetting, + resolveScope, + resolveOwnershipConfig, + listBindingScopes, + accessContext, + resolveOwnerForScope, + resolveIntegrationOwner, + integrationOwnerKind, + pickExistingEntity, + visibilityClauses, + describeRecord, + decideLevel, + applyRequest, + matchesListFilters, + createScopePolicy, + OwnershipConfigError, +} = require('./entity-scope'); + +/** + * A User as the domain sees it: `individual` is the caller's own user (with the + * legacy `organizationId` pointer), `org` the organization this request acts in. + */ +const user = ({ + individual = { id: '7', organizationId: null }, + org = null, + primary = 'individual', +} = {}) => ({ + config: { primary }, + getId: () => (primary === 'organization' ? org?.id : individual?.id), + getIndividualUser: () => individual, + getActiveOrganizationId: () => (org ? String(org.id) : null), +}); + +const ctxOf = (options, individualAcross) => + accessContext(user(options), { individualAcross }); + +describe('resolveScope (ADR-056 §3.1)', () => { + it('falls back to individual, or organization for an organization-primary app', () => { + expect(resolveScope({ primary: 'individual' })).toMatchObject({ + scope: 'individual', + source: 'default', + }); + expect(resolveScope({})).toMatchObject({ scope: 'individual' }); + expect(resolveScope({ primary: 'organization' })).toMatchObject({ + scope: 'organization', + source: 'default', + }); + }); + + // Each level is set to a different scope and the one nearer the top must win. + const levels = ['binding', 'integration', 'entityType', 'app']; + it.each(levels.map((name, i) => [name, i]))( + '%s overrides every level below it, and nothing intersects', + (name, index) => { + const all = { + binding: 'global', + integration: 'organization', + entityType: 'individual', + app: 'organization', + }; + const given = {}; + for (const level of levels.slice(index)) given[level] = all[level]; + expect(resolveScope(given)).toMatchObject({ + scope: all[name], + source: name, + }); + } + ); + + it('uses the default of a { default, allow } setting', () => { + expect( + resolveScope({ entityType: { default: 'organization', allow: ['organization', 'individual'] } }) + ).toMatchObject({ scope: 'organization', allow: ['organization', 'individual'] }); + }); + + it('lets a request choose only from the deciding level allow list', () => { + const entityType = { default: 'organization', allow: ['organization', 'individual'] }; + expect(resolveScope({ request: 'individual', entityType })).toMatchObject({ + scope: 'individual', + source: 'request', + decidedBy: 'entityType', + }); + // The app level says individual-only; the entity type level decides, so the + // app's list is not consulted (nothing intersects). + expect( + resolveScope({ request: 'individual', entityType, app: 'organization' }) + ).toMatchObject({ scope: 'individual' }); + }); + + it('refuses a request outside the allow list with 400 and names the list', () => { + expect.assertions(3); + try { + resolveScope({ request: 'organization', entityType: 'individual' }); + } catch (error) { + // The same refusal as a request for global: the app does not offer it. + expect(error.output.statusCode).toBe(403); + expect(error.data.code).toBe('SCOPE_NOT_ALLOWED'); + expect(error.data.details.allowed).toEqual(['individual']); + } + }); + + it('lets a string setting be requested only as itself', () => { + expect(resolveScope({ request: 'organization', app: 'organization' })).toMatchObject({ + scope: 'organization', + }); + expect(() => resolveScope({ request: 'individual', app: 'organization' })).toThrow( + /not allowed/ + ); + }); + + it('never gives a tenant request global, even where the level allows it', () => { + expect.assertions(2); + try { + resolveScope({ request: 'global', entityType: { default: 'global', allow: ['global'] } }); + } catch (error) { + expect(error.output.statusCode).toBe(403); + expect(error.data.code).toBe('SCOPE_NOT_ALLOWED'); + } + }); + + it('rejects a request that is not a scope', () => { + expect(() => resolveScope({ request: 'team' })).toThrow(/scope must be one of/); + }); +}); + +describe('parseScopeSetting', () => { + it('is null when a level sets nothing', () => { + expect(parseScopeSetting(undefined, 'x')).toBeNull(); + expect(parseScopeSetting(null, 'x')).toBeNull(); + }); + + it('reads a string as default and the only allowed value', () => { + expect(parseScopeSetting('organization', 'x')).toEqual({ + default: 'organization', + allow: ['organization'], + }); + }); + + it('reads allow-only with one entry as its own default, and default-only as allow of one', () => { + expect(parseScopeSetting({ allow: ['individual'] }, 'x')).toEqual({ + default: 'individual', + allow: ['individual'], + }); + expect(parseScopeSetting({ default: 'organization' }, 'x')).toEqual({ + default: 'organization', + allow: ['organization'], + }); + }); + + it('is idempotent on its own output (the resolved config round-trips)', () => { + const parsed = parseScopeSetting({ default: 'organization', allow: ['organization', 'individual'] }, 'x'); + expect(parseScopeSetting(parsed, 'x')).toEqual(parsed); + }); + + it.each([ + ['team', 'not one of'], + [{ default: 'individual', allow: ['organization'] }, 'must be in scope.allow'], + [{ allow: ['individual', 'organization'] }, 'needs a default'], + [{ default: 'individual', allow: [] }, 'one or more'], + [{ default: 'individual', allow: ['individual', 'individual'] }, 'once each'], + [{ default: 'individual', extra: 1 }, 'unknown scope setting'], + [42, 'scope name or'], + ])('rejects %j', (setting, message) => { + expect(() => parseScopeSetting(setting, 'app scope')).toThrow(OwnershipConfigError); + expect(() => parseScopeSetting(setting, 'app scope')).toThrow(message); + }); +}); + +describe('resolveOwnershipConfig (checked at app-definition load)', () => { + const module = (name) => ({ definition: { moduleName: name } }); + const integration = (name, definition = {}) => ({ + Definition: { name, modules: { crm: module('hubspot') }, ...definition }, + }); + const registry = (scope) => + new Map([['hubspot', { settings: scope === undefined ? {} : { scope } }]]); + + it('returns the app level and individualAcross, defaulting to organization', () => { + expect(resolveOwnershipConfig({ integrations: [] }, new Map())).toEqual({ + scope: null, + individualAcross: 'organization', + }); + expect( + resolveOwnershipConfig( + { ownership: { scope: 'organization', individualAcross: 'all' }, integrations: [] }, + new Map() + ) + ).toEqual({ + scope: { default: 'organization', allow: ['organization'] }, + individualAcross: 'all', + }); + }); + + it.each([ + [{ ownership: { individualAcross: 'everywhere' } }, 'individualAcross'], + [{ ownership: { scope: 'team' } }, 'appDefinition.ownership.scope'], + [{ ownership: { owner: 'organization' } }, 'unknown setting'], + [{ ownership: 'organization' }, 'must be an object'], + [{ integrations: [integration('a', { ownership: { owner: 'team' } })] }, 'owner must be one of'], + [{ integrations: [integration('a', { ownership: { scope: 'team' } })] }, "integration 'a' ownership"], + [{ integrations: [integration('a', { modules: { crm: { ...module('hubspot'), scope: 'team' } } })] }, "binding 'crm'"], + ])('rejects %j', (appDefinition, message) => { + expect(() => resolveOwnershipConfig(appDefinition, registry())).toThrow(OwnershipConfigError); + expect(() => resolveOwnershipConfig(appDefinition, registry())).toThrow(message); + }); + + it('rejects a bad registry scope', () => { + expect(() => resolveOwnershipConfig({ integrations: [] }, registry('team'))).toThrow( + "entity type 'hubspot' scope" + ); + }); + + it('rejects an organization-owned integration that binds an individual entity (§3.3)', () => { + const app = { + user: { primary: 'individual' }, + integrations: [integration('shared', { ownership: { owner: 'organization' } })], + }; + // The default for an individual-primary app is individual. + expect(() => resolveOwnershipConfig(app, registry())).toThrow(/cannot bind an 'individual' entity/); + // Setting the binding (or the type) to organization makes it valid. + expect(() => + resolveOwnershipConfig( + { ...app, integrations: [integration('shared', { ownership: { owner: 'organization', scope: 'organization' } })] }, + registry() + ) + ).not.toThrow(); + expect(() => resolveOwnershipConfig(app, registry('organization'))).not.toThrow(); + // An organization-primary app defaults to organization, so it is valid as written. + expect(() => + resolveOwnershipConfig({ ...app, user: { primary: 'organization' } }, registry()) + ).not.toThrow(); + }); + + it('accepts the deprecated entities[key].global alias as scope global', () => { + const rows = listBindingScopes( + { + integrations: [ + integration('a', { entities: { crm: { global: true } } }), + ], + }, + registry(), + 'individual' + ); + expect(rows).toEqual([ + expect.objectContaining({ binding: 'crm', scope: 'global', source: 'binding' }), + ]); + }); + + it('lists every binding with its resolved scope and where it came from', () => { + const rows = listBindingScopes( + { + ownership: { scope: 'organization' }, + integrations: [ + integration('a'), + integration('b', { ownership: { scope: 'individual' } }), + ], + }, + registry('individual'), + 'individual' + ); + expect(rows).toEqual([ + expect.objectContaining({ integration: 'a', scope: 'individual', source: 'entityType' }), + expect.objectContaining({ integration: 'b', scope: 'individual', source: 'integration' }), + ]); + }); +}); + +describe('resolveOwnerForScope (§3.2, §3.6)', () => { + it('individual: the person, in the active organization', () => { + expect( + resolveOwnerForScope('individual', ctxOf({ org: { id: 17 } })) + ).toEqual({ scope: 'individual', ownerId: '7', organizationId: '17' }); + }); + + it('individual with no organization: organizationId null', () => { + expect(resolveOwnerForScope('individual', ctxOf({}))).toEqual({ + scope: 'individual', + ownerId: '7', + organizationId: null, + }); + }); + + it("individual under individualAcross 'all': stored with no organization", () => { + expect( + resolveOwnerForScope('individual', ctxOf({ org: { id: 17 } }, 'all')) + ).toEqual({ scope: 'individual', ownerId: '7', organizationId: null }); + }); + + it('organization: the active organization user, in that organization', () => { + expect( + resolveOwnerForScope('organization', ctxOf({ org: { id: 17 } })) + ).toEqual({ scope: 'organization', ownerId: '17', organizationId: '17' }); + }); + + it('organization never takes the caller as owner, whatever user.primary says', () => { + const owner = resolveOwnerForScope( + 'organization', + ctxOf({ org: { id: 17 }, primary: 'individual' }) + ); + expect(owner.ownerId).toBe('17'); + const individual = resolveOwnerForScope( + 'individual', + ctxOf({ org: { id: 17 }, primary: 'organization' }) + ); + expect(individual.ownerId).toBe('7'); + }); + + it('400s, naming the header, when the identity the scope needs is missing', () => { + expect.assertions(4); + try { + resolveOwnerForScope('organization', ctxOf({})); + } catch (error) { + expect(error.output.statusCode).toBe(400); + expect(error.data.details.field).toBe('x-frigg-apporgid'); + } + try { + resolveOwnerForScope('individual', ctxOf({ individual: null, org: { id: 17 } })); + } catch (error) { + expect(error.output.statusCode).toBe(400); + expect(error.data.details.field).toBe('x-frigg-appuserid'); + } + }); + + it('refuses global with a clear error', () => { + expect.assertions(2); + try { + resolveOwnerForScope('global', ctxOf({ org: { id: 17 } })); + } catch (error) { + expect(error.output.statusCode).toBe(403); + expect(error.message).toMatch(/app owner through admin routes/); + } + }); +}); + +describe('integrationOwnerKind (§3 table, §3.3)', () => { + it.each([ + [['individual'], 'individual'], + [['individual', 'individual'], 'individual'], + [['organization'], 'organization'], + [['organization', 'organization'], 'organization'], + // Mixed: derived as the person. ResolveIntegrationOwnership then refuses it. + [['organization', 'individual'], 'individual'], + [[], 'individual'], + ])('%j -> %s', (scopes, owner) => { + expect(integrationOwnerKind(scopes)).toBe(owner); + }); + + it('lets an explicit rule override the derivation', () => { + expect(integrationOwnerKind(['organization'], 'individual')).toBe('individual'); + expect(integrationOwnerKind(['individual'], 'organization')).toBe('organization'); + expect(integrationOwnerKind(['organization'], 'derive')).toBe('organization'); + }); + + it('resolveIntegrationOwner pairs the integration with the active organization', () => { + expect(resolveIntegrationOwner('individual', ctxOf({ org: { id: 17 } }))).toEqual({ + ownerId: '7', + organizationId: '17', + }); + expect(resolveIntegrationOwner('organization', ctxOf({ org: { id: 17 } }))).toEqual({ + ownerId: '17', + organizationId: '17', + }); + expect(() => resolveIntegrationOwner('organization', ctxOf({}))).toThrow(/no active organization/); + expect(() => resolveIntegrationOwner('individual', ctxOf({ individual: null }))).toThrow(/no individual user/); + }); +}); + +describe('visibilityClauses (§3.6 list rule and legacy rows)', () => { + const active = { org: { id: 17 } }; + + it('active organization: its own rows, plus the caller in that organization', () => { + expect(visibilityClauses(ctxOf(active), 'entity')).toEqual([ + { userId: '17', organizationId: '17' }, + { userId: '17', organizationId: null }, + { userId: '7', organizationId: '17' }, + ]); + }); + + it('no active organization: the caller rows that have none, as before scoping', () => { + expect(visibilityClauses(ctxOf({}), 'entity')).toEqual([ + { userId: '7', organizationId: null }, + ]); + }); + + it("'all' adds the caller's organization-independent entities, only for entities", () => { + expect(visibilityClauses(ctxOf(active, 'all'), 'entity')).toContainEqual({ + userId: '7', + organizationId: null, + }); + // An integration always belongs to the organization it was created in. + expect(visibilityClauses(ctxOf(active, 'all'), 'integration')).not.toContainEqual({ + userId: '7', + organizationId: null, + }); + }); + + it('legacy: unscoped individual rows show in the legacy organization of the person, and only there', () => { + const inLegacyOrg = ctxOf({ individual: { id: '7', organizationId: '17' }, org: { id: 17 } }); + expect(visibilityClauses(inLegacyOrg, 'entity')).toContainEqual({ userId: '7', organizationId: null }); + expect(visibilityClauses(inLegacyOrg, 'integration')).toContainEqual({ userId: '7', organizationId: null }); + + const inOtherOrg = ctxOf({ individual: { id: '7', organizationId: '99' }, org: { id: 17 } }); + expect(visibilityClauses(inOtherOrg, 'entity')).not.toContainEqual({ userId: '7', organizationId: null }); + const noPointer = ctxOf({ individual: { id: '7', organizationId: null }, org: { id: 17 } }); + expect(visibilityClauses(noPointer, 'entity')).not.toContainEqual({ userId: '7', organizationId: null }); + }); + + it('legacy: organization-owned unscoped rows belong to their owner', () => { + expect(visibilityClauses(ctxOf(active), 'entity')).toContainEqual({ + userId: '17', + organizationId: null, + }); + }); + + it('an organization-primary request with no individual sees only the organization', () => { + expect( + visibilityClauses(ctxOf({ individual: null, org: { id: 17 }, primary: 'organization' }), 'entity') + ).toEqual([ + { userId: '17', organizationId: '17' }, + { userId: '17', organizationId: null }, + ]); + }); +}); + +describe('describeRecord: the list and the access check agree (§3.4)', () => { + const active = { org: { id: 17 } }; + const row = (userId, organizationId, extra = {}) => ({ id: 'x', userId, organizationId, ...extra }); + + it('the caller individual entity in the active organization: manage', () => { + expect(describeRecord(row('7', '17'), ctxOf(active), 'entity')).toEqual({ + scope: 'individual', + owner: { type: 'individual', id: '7' }, + organizationId: '17', + access: 'manage', + }); + }); + + it('an organization entity: scope organization, owner the organization', () => { + expect(describeRecord(row('17', '17'), ctxOf(active), 'entity')).toEqual({ + scope: 'organization', + owner: { type: 'organization', id: '17' }, + organizationId: '17', + access: 'manage', + }); + }); + + it("never shows the caller's entity paired with another organization", () => { + expect(describeRecord(row('7', '99'), ctxOf(active), 'entity')).toBeNull(); + expect(describeRecord(row('7', '99'), ctxOf({}), 'entity')).toBeNull(); + }); + + it("never shows somebody else's entity, in or out of the active organization", () => { + expect(describeRecord(row('8', '17'), ctxOf(active), 'entity')).toBeNull(); + expect(describeRecord(row('8', null), ctxOf({}), 'entity')).toBeNull(); + }); + + it('never shows another organization entity', () => { + expect(describeRecord(row('99', '99'), ctxOf(active), 'entity')).toBeNull(); + }); + + it('never describes a global entity to a tenant', () => { + expect(describeRecord(row(null, null), ctxOf(active), 'entity')).toBeNull(); + expect(describeRecord(row(undefined, undefined), ctxOf({}), 'entity')).toBeNull(); + }); + + it("'all': an organization-independent individual entity shows in every organization, and only that one", () => { + for (const org of [17, 18]) { + expect(describeRecord(row('7', null), ctxOf({ org: { id: org } }, 'all'), 'entity')).toMatchObject({ + scope: 'individual', + organizationId: null, + }); + } + // Not under the default, unless it is the legacy organization. + expect(describeRecord(row('7', null), ctxOf(active), 'entity')).toBeNull(); + // Somebody else's, still not visible. + expect(describeRecord(row('8', null), ctxOf(active, 'all'), 'entity')).toBeNull(); + }); + + it("'all' does not apply to integrations", () => { + expect(describeRecord(row('7', null), ctxOf(active, 'all'), 'integration')).toBeNull(); + }); + + it('legacy rows keep behaving as they did: an app with no organizations sees its null-org entities', () => { + expect(describeRecord(row('7', null), ctxOf({}), 'entity')).toMatchObject({ + scope: 'individual', + access: 'manage', + }); + expect(describeRecord(row('7', undefined), ctxOf({}), 'integration')).not.toBeNull(); + }); + + it('legacy rows in an organization app belong to the legacy organization of the owner', () => { + const mine = { individual: { id: '7', organizationId: '17' }, org: { id: 17 } }; + expect(describeRecord(row('7', null), ctxOf(mine), 'entity')).not.toBeNull(); + expect(describeRecord(row('7', null), ctxOf(mine), 'integration')).not.toBeNull(); + // The same person acting in a second organization does not see them. + expect(describeRecord(row('7', null), ctxOf({ ...mine, org: { id: 18 } }), 'entity')).toBeNull(); + }); + + it('legacy organization-owned rows (no organizationId) are the organization own', () => { + expect(describeRecord(row('17', null), ctxOf(active), 'entity')).toMatchObject({ + scope: 'organization', + organizationId: '17', + }); + }); + + it('matches what a { getId } test double has always meant: the id is the owner', () => { + const policy = createScopePolicy(); + expect(policy.canUseEntity({ getId: () => 'u1' }, { id: 'e', userId: 'u1' })).toBe(true); + expect(policy.canUseEntity({ getId: () => 'u1' }, { id: 'e', userId: 'u2' })).toBe(false); + }); + + it('describeRecord checks a row against the clauses the context carries, without rebuilding them', () => { + const ctx = ctxOf({ org: { id: 17 } }); + const row = { userId: '7', organizationId: '17' }; + expect(describeRecord(row, ctx, 'entity')).not.toBeNull(); + // Same caller, but a context whose clauses say nothing is visible. + expect(describeRecord(row, { ...ctx, clauses: { entity: [], integration: [] } }, 'entity')).toBeNull(); + }); + + it('builds the clauses once per request, not once per row', () => { + const ctx = ctxOf({ org: { id: 17 } }); + expect(visibilityClauses(ctx, 'entity')).toBe(ctx.clauses.entity); + expect(visibilityClauses(ctx, 'entity')).toBe(visibilityClauses(ctx, 'entity')); + const user = { getId: () => '7', getIndividualUser: jest.fn(() => ({ id: '7' })) }; + const once = accessContext(user); + describeRecord({ userId: '7' }, once, 'entity'); + describeRecord({ userId: '7' }, once, 'entity'); + expect(user.getIndividualUser).toHaveBeenCalledTimes(1); + }); + + it('compares ids as strings: stores return numbers or strings', () => { + expect(describeRecord(row(7, 17), ctxOf(active), 'entity')).not.toBeNull(); + }); +}); + +describe('pickExistingEntity (re-authorization)', () => { + const entity = (id, organizationId) => ({ id, organizationId }); + + it('prefers the entity of the same organization: the same account in two organizations is two entities', () => { + const picked = pickExistingEntity([entity('a', '17'), entity('b', '18')], { + scope: 'individual', + organizationId: '18', + legacyOrgId: null, + }); + expect(picked.id).toBe('b'); + }); + + it('finds nothing when the account was only connected in another organization', () => { + expect( + pickExistingEntity([entity('a', '17')], { scope: 'individual', organizationId: '18', legacyOrgId: '99' }) + ).toBeNull(); + }); + + it('matches an organization-independent entity when there is no organization', () => { + expect( + pickExistingEntity([entity('a', null), entity('b', '17')], { + scope: 'individual', + organizationId: null, + }).id + ).toBe('a'); + }); + + it('reuses a legacy unscoped individual entity only in the legacy organization', () => { + const legacy = [entity('old', null)]; + expect( + pickExistingEntity(legacy, { scope: 'individual', organizationId: '17', legacyOrgId: '17' }).id + ).toBe('old'); + expect( + pickExistingEntity(legacy, { scope: 'individual', organizationId: '17', legacyOrgId: '99' }) + ).toBeNull(); + }); + + it('reuses a legacy unscoped organization entity: the owner is the organization', () => { + expect( + pickExistingEntity([entity('old', null)], { scope: 'organization', organizationId: '17', legacyOrgId: null }).id + ).toBe('old'); + }); + + it('prefers an exact match over a legacy row', () => { + expect( + pickExistingEntity([entity('old', null), entity('new', '17')], { + scope: 'individual', + organizationId: '17', + legacyOrgId: '17', + }).id + ).toBe('new'); + }); +}); + +describe('list filters (§3.4)', () => { + it('filters by scope and by owner', () => { + const mine = { scope: 'individual', owner: { type: 'individual', id: '7' } }; + const theirs = { scope: 'organization', owner: { type: 'organization', id: '17' } }; + expect(matchesListFilters(mine, {})).toBe(true); + expect(matchesListFilters(mine, { scope: 'organization' })).toBe(false); + expect(matchesListFilters(theirs, { owner: 'org' })).toBe(true); + expect(matchesListFilters(theirs, { owner: 'me' })).toBe(false); + expect(matchesListFilters(mine, { owner: 'me', scope: 'individual' })).toBe(true); + }); +}); + +describe('resolveScope is decideLevel then applyRequest', () => { + it('decideLevel names the deciding level and its allow list, with no request', () => { + expect(decideLevel({ entityType: 'organization', app: 'individual' })).toEqual({ + default: 'organization', + allow: ['organization'], + source: 'entityType', + }); + expect(decideLevel({ primary: 'organization' })).toEqual({ + default: 'organization', + allow: ['organization'], + source: 'default', + }); + }); + + it('applyRequest changes nothing without a request and refuses an unknown scope', () => { + const decided = decideLevel({ app: { default: 'individual', allow: ['individual', 'organization'] } }); + expect(applyRequest(decided, undefined)).toMatchObject({ scope: 'individual', source: 'app' }); + expect(applyRequest(decided, 'organization')).toMatchObject({ scope: 'organization', source: 'request', decidedBy: 'app' }); + expect(() => applyRequest(decided, 'team')).toThrow(/scope must be one of/); + }); +}); + +describe('createScopePolicy', () => { + const registry = new Map([ + ['google', { settings: { scope: 'individual' } }], + ['xero', { settings: { scope: { default: 'organization', allow: ['organization', 'individual'] } } }], + ['plain', { settings: {} }], + ]); + + it('resolves the owner from the registry scope, not from user.primary', () => { + const policy = createScopePolicy({ entityTypeRegistry: registry }); + const caller = user({ org: { id: 17 }, primary: 'organization' }); + expect(policy.resolveOwnership({ entityType: 'google', user: caller })).toMatchObject({ + scope: 'individual', + ownerId: '7', + organizationId: '17', + }); + expect(policy.resolveOwnership({ entityType: 'xero', user: user({ org: { id: 17 } }) })).toMatchObject({ + scope: 'organization', + ownerId: '17', + }); + }); + + it('lets the request choose what the registry allows, and no more', () => { + const policy = createScopePolicy({ entityTypeRegistry: registry }); + const caller = user({ org: { id: 17 } }); + expect( + policy.resolveOwnership({ entityType: 'xero', requestScope: 'individual', user: caller }) + ).toMatchObject({ scope: 'individual', ownerId: '7', source: 'request' }); + expect(() => + policy.resolveOwnership({ entityType: 'google', requestScope: 'organization', user: caller }) + ).toThrow(/not allowed/); + }); + + it('falls back to the app level, then to user.primary', () => { + const withApp = createScopePolicy({ + ownership: { scope: 'organization' }, + entityTypeRegistry: registry, + }); + expect(withApp.resolveScope({ entityType: 'plain', user: user() })).toMatchObject({ + scope: 'organization', + source: 'app', + }); + const bare = createScopePolicy({ entityTypeRegistry: registry }); + expect(bare.resolveScope({ entityType: 'plain', user: user() })).toMatchObject({ + scope: 'individual', + source: 'default', + }); + expect(bare.resolveScope({ entityType: 'plain', user: user({ primary: 'organization' }) })).toMatchObject({ + scope: 'organization', + source: 'default', + }); + }); + + it('carries individualAcross into the access context and the ownership', () => { + const policy = createScopePolicy({ ownership: { individualAcross: 'all' } }); + expect(policy.individualAcross).toBe('all'); + expect( + policy.resolveOwnership({ entityType: 'google', user: user({ org: { id: 17 } }) }) + ).toMatchObject({ organizationId: null }); + }); + + it('refuses an unknown individualAcross', () => { + expect(() => createScopePolicy({ ownership: { individualAcross: 'x' } })).toThrow(OwnershipConfigError); + }); + + it('lets a pre-resolved app config (as loadAppDefinition returns it) be passed back in', () => { + const resolved = resolveOwnershipConfig( + { ownership: { scope: { default: 'organization', allow: ['organization', 'individual'] } }, integrations: [] }, + new Map() + ); + const policy = createScopePolicy({ ownership: resolved }); + expect(policy.resolveScope({ entityType: 'x', user: user(), requestScope: 'individual' }).scope).toBe( + 'individual' + ); + }); +}); diff --git a/packages/core/modules/index.js b/packages/core/modules/index.js index c59ea26a7..957464a1e 100644 --- a/packages/core/modules/index.js +++ b/packages/core/modules/index.js @@ -8,6 +8,11 @@ const { getEntityTypeRegistry, EntityRegistryError, } = require('./entity-type-registry'); +const { + resolveScope, + listBindingScopes, + OwnershipConfigError, +} = require('./entity-scope'); module.exports = { ApiKeyRequester, @@ -18,4 +23,7 @@ module.exports = { ModuleFactory, getEntityTypeRegistry, EntityRegistryError, + resolveScope, + listBindingScopes, + OwnershipConfigError, }; diff --git a/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js b/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js new file mode 100644 index 000000000..65ac16325 --- /dev/null +++ b/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js @@ -0,0 +1,184 @@ +/** + * Request-shape tests for the organization column (ADR-056 §3.6): what each + * adapter asks its database for. A mocked return value cannot tell a query + * that filters by organization from one that does not, so these assert on the + * query the adapter sends. + */ +jest.mock('../../../database/prisma', () => ({ prisma: { $runCommandRaw: jest.fn() } })); +jest.mock('../../../database/documentdb-encryption-service'); + +const { ModuleRepositoryPostgres } = require('../module-repository-postgres'); +const { ModuleRepositoryMongo } = require('../module-repository-mongo'); +const { ModuleRepositoryDocumentDB } = require('../module-repository-documentdb'); +const { ObjectId } = require('bson'); + +const clauses = [ + { userId: '17', organizationId: '17' }, + { userId: '17', organizationId: null }, + { userId: '7', organizationId: '17' }, +]; + +const prismaFor = (rows = []) => ({ + entity: { + findMany: jest.fn().mockResolvedValue(rows), + create: jest.fn(async ({ data }) => ({ id: 1, ...data })), + findFirst: jest.fn().mockResolvedValue(null), + }, + credential: { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null) }, +}); + +describe('ModuleRepositoryPostgres', () => { + const make = (rows) => { + const repo = new ModuleRepositoryPostgres(); + repo.prisma = prismaFor(rows); + return repo; + }; + + it('findEntitiesByOwnership is one query: an OR of owner/organization pairs, null as null', async () => { + const repo = make([{ id: 5, userId: 7, organizationId: 17, name: 'n', moduleName: 'm', data: {} }]); + const found = await repo.findEntitiesByOwnership(clauses); + expect(repo.prisma.entity.findMany).toHaveBeenCalledTimes(1); + expect(repo.prisma.entity.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { userId: 17, organizationId: 17 }, + { userId: 17, organizationId: null }, + { userId: 7, organizationId: 17 }, + ], + }, + }); + expect(found[0]).toMatchObject({ id: '5', userId: '7', organizationId: '17' }); + }); + + it('asks for nothing when there are no clauses', async () => { + const repo = make(); + expect(await repo.findEntitiesByOwnership([])).toEqual([]); + expect(repo.prisma.entity.findMany).not.toHaveBeenCalled(); + }); + + it('createEntity stores the organization from the tenancy argument, never from entity data', async () => { + const repo = make(); + await repo.createEntity( + { user: '7', name: 'n', moduleName: 'm', externalId: 'x', organizationId: 'provider-org-9' }, + { organizationId: '17' } + ); + const { data } = repo.prisma.entity.create.mock.calls[0][0]; + expect(data.organizationId).toBe(17); + // A provider field with the same name stays provider data. + expect(data.data).toEqual({ organizationId: 'provider-org-9' }); + }); + + it('createEntity with no tenancy stores no organization', async () => { + const repo = make(); + await repo.createEntity({ user: '7', moduleName: 'm' }); + expect(repo.prisma.entity.create.mock.calls[0][0].data.organizationId).toBeNull(); + }); + + it('returns the framework organizationId even when provider data carries one', async () => { + const repo = make([ + { id: 5, userId: 7, organizationId: 17, moduleName: 'm', data: { organizationId: 'provider-org-9' } }, + ]); + const [entity] = await repo.findEntitiesByOwnership(clauses); + expect(entity.organizationId).toBe('17'); + }); + + it('filters findEntities by organization; null means none, absent means any', async () => { + const repo = make(); + await repo.findEntities({ user: '7', organizationId: null }); + expect(repo.prisma.entity.findMany).toHaveBeenLastCalledWith({ + where: { userId: 7, organizationId: null }, + }); + await repo.findEntities({ user: '7', organizationId: '17' }); + expect(repo.prisma.entity.findMany).toHaveBeenLastCalledWith({ + where: { userId: 7, organizationId: 17 }, + }); + await repo.findEntities({ user: '7' }); + expect(repo.prisma.entity.findMany).toHaveBeenLastCalledWith({ where: { userId: 7 } }); + }); +}); + +describe('ModuleRepositoryMongo', () => { + const make = (rows) => { + const repo = new ModuleRepositoryMongo(); + repo.prisma = prismaFor(rows); + return repo; + }; + const unset = (userId) => ({ + userId, + OR: [{ organizationId: null }, { organizationId: { isSet: false } }], + }); + + it('matches a null organization as null OR unset: rows written before the column have no field', async () => { + const repo = make(); + await repo.findEntitiesByOwnership(clauses); + expect(repo.prisma.entity.findMany).toHaveBeenCalledTimes(1); + expect(repo.prisma.entity.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { userId: '17', organizationId: '17' }, + unset('17'), + { userId: '7', organizationId: '17' }, + ], + }, + }); + }); + + it('createEntity stores the organization from the tenancy argument only', async () => { + const repo = make(); + await repo.createEntity({ user: 'u', moduleName: 'm', organizationId: 'provider' }, { organizationId: 'o1' }); + const { data } = repo.prisma.entity.create.mock.calls[0][0]; + expect(data.organizationId).toBe('o1'); + expect(data.data).toEqual({ organizationId: 'provider' }); + }); + + it('filters findEntities for no organization as null OR unset', async () => { + const repo = make(); + await repo.findEntities({ user: 'u', organizationId: null }); + expect(repo.prisma.entity.findMany).toHaveBeenCalledWith({ + where: { userId: 'u', OR: [{ organizationId: null }, { organizationId: { isSet: false } }] }, + }); + }); +}); + +describe('ModuleRepositoryDocumentDB', () => { + const ids = { u17: new ObjectId(), u7: new ObjectId() }; + const make = () => { + const repo = new ModuleRepositoryDocumentDB(); + const $runCommandRaw = jest.fn(async (command) => { + if (command.find) return { cursor: { firstBatch: [] } }; + if (command.insert) return { ok: 1, n: 1 }; + return { cursor: { firstBatch: [] } }; + }); + repo.prisma = { $runCommandRaw }; + return { repo, $runCommandRaw }; + }; + + it('findEntitiesByOwnership is one raw find with $or of owner/organization pairs', async () => { + const { repo, $runCommandRaw } = make(); + await repo.findEntitiesByOwnership([ + { userId: ids.u17.toString(), organizationId: ids.u17.toString() }, + { userId: ids.u17.toString(), organizationId: null }, + { userId: ids.u7.toString(), organizationId: ids.u17.toString() }, + ]); + const finds = $runCommandRaw.mock.calls.map(([c]) => c).filter((c) => c.find === 'Entity'); + expect(finds).toHaveLength(1); + const { $or } = finds[0].filter; + expect($or).toHaveLength(3); + expect($or[0].userId.toString()).toBe(ids.u17.toString()); + expect($or[0].organizationId.toString()).toBe(ids.u17.toString()); + // A raw `{ field: null }` matches a null value and a missing field. + expect($or[1].organizationId).toBeNull(); + expect($or[2].userId.toString()).toBe(ids.u7.toString()); + }); + + it('stores the organization on insert from the tenancy argument only', async () => { + const { repo, $runCommandRaw } = make(); + await repo.createEntity({ user: ids.u7.toString(), moduleName: 'm', organizationId: 'provider' }, { + organizationId: ids.u17.toString(), + }); + const insert = $runCommandRaw.mock.calls.map(([c]) => c).find((c) => c.insert === 'Entity'); + const [doc] = insert.documents; + expect(doc.organizationId.toString()).toBe(ids.u17.toString()); + expect(doc.data).toEqual({ organizationId: 'provider' }); + }); +}); diff --git a/packages/core/modules/repositories/module-repository-documentdb.js b/packages/core/modules/repositories/module-repository-documentdb.js index 0e4571314..d3d5510dd 100644 --- a/packages/core/modules/repositories/module-repository-documentdb.js +++ b/packages/core/modules/repositories/module-repository-documentdb.js @@ -76,6 +76,28 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface { return docs.map((doc) => this._mapEntity(doc, credentialMap.get(fromObjectId(doc.credentialId)) || null)); } + /** + * Find entities matching any of the owner/organization pairs, in one query. + * `organizationId: null` matches rows with no organization (a raw MongoDB + * `null` also matches a missing field). The caller (a use case) decides + * which pairs; this only runs them. + * + * @param {Array<{userId: string, organizationId: (string|null)}>} clauses + * @returns {Promise} + */ + async findEntitiesByOwnership(clauses) { + if (!Array.isArray(clauses) || clauses.length === 0) return []; + const docs = await findMany(this.prisma, 'Entity', { + $or: clauses.map(({ userId, organizationId }) => ({ + userId: toObjectId(userId), + organizationId: + organizationId === null ? null : toObjectId(organizationId), + })), + }); + const credentialMap = await this._fetchCredentialsBulk(docs.map((doc) => doc.credentialId)); + return docs.map((doc) => this._mapEntity(doc, credentialMap.get(fromObjectId(doc.credentialId)) || null)); + } + async unsetCredential(entityId) { const objectId = toObjectId(entityId); if (!objectId) return false; @@ -115,7 +137,14 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface { ); } - async createEntity(entityData) { + /** + * @param {Object} entityData + * @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity + * belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`, + * because `entityData` carries provider fields and one named `organizationId` + * must not be taken for ours. + */ + async createEntity(entityData, tenancy = {}) { const { user, userId, @@ -133,6 +162,7 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface { name: name ?? null, moduleName: moduleName ?? null, externalId: externalId ?? null, + organizationId: toObjectId(tenancy.organizationId) || null, data: dynamicData, }; const insertedId = await insertOne(this.prisma, 'Entity', document); @@ -329,6 +359,14 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface { if (filter.name) query.name = filter.name; if (filter.moduleName) query.moduleName = filter.moduleName; if (filter.externalId) query.externalId = filter.externalId; + // `null` means "no organization"; absent means "any". A raw MongoDB + // `{ field: null }` matches both a null value and a missing field. + if (filter.organizationId !== undefined) { + query.organizationId = + filter.organizationId === null + ? null + : toObjectId(filter.organizationId); + } return query; } @@ -342,6 +380,8 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface { externalId: doc?.externalId ?? null, moduleName: doc?.moduleName ?? null, ...dynamicData, + // After the spread: a provider field of the same name must not decide visibility. + organizationId: fromObjectId(doc?.organizationId) ?? null, }; } } diff --git a/packages/core/modules/repositories/module-repository-interface.js b/packages/core/modules/repositories/module-repository-interface.js index 1bd64f6ef..5b7decc78 100644 --- a/packages/core/modules/repositories/module-repository-interface.js +++ b/packages/core/modules/repositories/module-repository-interface.js @@ -67,6 +67,21 @@ class ModuleRepositoryInterface { ); } + /** + * Find entities matching any of the owner/organization pairs, in one query + * (ADR-056 §3.6). `organizationId: null` matches rows with no organization. + * Which pairs is decided by the caller (modules/entity-scope.js), not here. + * + * @param {Array<{userId: string|number, organizationId: (string|number|null)}>} clauses + * @returns {Promise} Array of entity objects + * @abstract + */ + async findEntitiesByOwnership(clauses) { + throw new Error( + 'Method findEntitiesByOwnership must be implemented by subclass' + ); + } + /** * Unset credential from entity * @@ -110,11 +125,13 @@ class ModuleRepositoryInterface { /** * Create a new entity * - * @param {Object} entityData - Entity data + * @param {Object} entityData - Entity data (provider fields included) + * @param {{organizationId?: (string|number|null)}} [tenancy] - the organization + * the entity belongs to (ADR-056 §3.6), kept apart from `entityData` * @returns {Promise} Created entity object * @abstract */ - async createEntity(entityData) { + async createEntity(entityData, tenancy) { throw new Error('Method createEntity must be implemented by subclass'); } diff --git a/packages/core/modules/repositories/module-repository-mongo.js b/packages/core/modules/repositories/module-repository-mongo.js index 1bb95dbbd..a4158f8e7 100644 --- a/packages/core/modules/repositories/module-repository-mongo.js +++ b/packages/core/modules/repositories/module-repository-mongo.js @@ -1,4 +1,5 @@ const { prisma } = require('../../database/prisma'); +const { organizationWhere } = require('../../database/organization-where'); const { ModuleRepositoryInterface } = require('./module-repository-interface'); /** @@ -76,6 +77,25 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { return credentialMap; } + /** + * The one place a stored entity row becomes the entity the application sees. + * `organizationId` comes after the entity data: a provider field of the same + * name must not decide who sees the entity. + * @private + */ + _mapEntity(row, credential) { + return { + id: row.id, + credential, + userId: row.userId, + name: row.name, + externalId: row.externalId, + moduleName: row.moduleName, + ...(row.data || {}), + organizationId: row.organizationId ?? null, + }; + } + /** * Find entity by ID with credential * Replaces: Entity.findById(entityId).populate('credential') @@ -95,15 +115,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { const credential = await this._fetchCredential(entity.credentialId); - return { - id: entity.id, - credential, - userId: entity.userId, - name: entity.name, - externalId: entity.externalId, - moduleName: entity.moduleName, - ...(entity.data || {}), - }; + return this._mapEntity(entity, credential); } /** @@ -121,15 +133,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { const credentialIds = entities.map(e => e.credentialId).filter(Boolean); const credentialMap = await this._fetchCredentialsBulk(credentialIds); - return entities.map((e) => ({ - id: e.id, - credential: credentialMap.get(e.credentialId) || null, - userId: e.userId, - name: e.name, - externalId: e.externalId, - moduleName: e.moduleName, - ...(e.data || {}), - })); + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); } /** @@ -147,15 +151,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { const credentialIds = entities.map(e => e.credentialId).filter(Boolean); const credentialMap = await this._fetchCredentialsBulk(credentialIds); - return entities.map((e) => ({ - id: e.id, - credential: credentialMap.get(e.credentialId) || null, - userId: e.userId, - name: e.name, - externalId: e.externalId, - moduleName: e.moduleName, - ...(e.data || {}), - })); + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); } /** @@ -177,15 +173,32 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { const credentialIds = entities.map(e => e.credentialId).filter(Boolean); const credentialMap = await this._fetchCredentialsBulk(credentialIds); - return entities.map((e) => ({ - id: e.id, - credential: credentialMap.get(e.credentialId) || null, - userId: e.userId, - name: e.name, - externalId: e.externalId, - moduleName: e.moduleName, - ...(e.data || {}), - })); + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); + } + + /** + * Find entities matching any of the owner/organization pairs, in one query. + * `organizationId: null` matches rows with no organization. The caller + * (a use case) decides which pairs; this only runs them. + * + * @param {Array<{userId: string, organizationId: (string|null)}>} clauses + * @returns {Promise} Array of entity objects with string IDs + */ + async findEntitiesByOwnership(clauses) { + if (!Array.isArray(clauses) || clauses.length === 0) return []; + const entities = await this.prisma.entity.findMany({ + where: { + OR: clauses.map(({ userId, organizationId }) => ({ + userId, + ...organizationWhere(organizationId), + })), + }, + }); + + const credentialIds = entities.map(e => e.credentialId).filter(Boolean); + const credentialMap = await this._fetchCredentialsBulk(credentialIds); + + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); } /** @@ -223,15 +236,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { const credential = await this._fetchCredential(entity.credentialId); - return { - id: entity.id, - credential, - userId: entity.userId, - name: entity.name, - externalId: entity.externalId, - moduleName: entity.moduleName, - ...(entity.data || {}), - }; + return this._mapEntity(entity, credential); } /** @@ -251,15 +256,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { .filter(Boolean); const credentialMap = await this._fetchCredentialsBulk(credentialIds); - return entities.map((e) => ({ - id: e.id, - credential: credentialMap.get(e.credentialId) || null, - userId: e.userId, - name: e.name, - externalId: e.externalId, - moduleName: e.moduleName, - ...(e.data || {}), - })); + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); } /** @@ -267,9 +264,13 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { * Replaces: Entity.create(entityData) * * @param {Object} entityData - Entity data + * @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity + * belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`, + * because `entityData` carries provider fields and one named `organizationId` + * must not be taken for ours. * @returns {Promise} Created entity object with string IDs */ - async createEntity(entityData) { + async createEntity(entityData, tenancy = {}) { const { user, userId, @@ -287,6 +288,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { name, moduleName, externalId, + organizationId: tenancy.organizationId ?? null, data: dynamicData, }; @@ -296,15 +298,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { const credentialObj = await this._fetchCredential(entity.credentialId); - return { - id: entity.id, - credential: credentialObj, - userId: entity.userId, - name: entity.name, - externalId: entity.externalId, - moduleName: entity.moduleName, - ...(entity.data || {}), - }; + return this._mapEntity(entity, credentialObj); } /** @@ -358,15 +352,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { const credentialObj = await this._fetchCredential(entity.credentialId); - return { - id: entity.id, - credential: credentialObj, - userId: entity.userId, - name: entity.name, - externalId: entity.externalId, - moduleName: entity.moduleName, - ...(entity.data || {}), - }; + return this._mapEntity(entity, credentialObj); } catch (error) { if (error.code === 'P2025') { return null; @@ -429,6 +415,11 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { if (filter.moduleName) where.moduleName = filter.moduleName; if (filter.externalId) where.externalId = this._toString(filter.externalId); + // `null` means "no organization"; absent means "any". + if (filter.organizationId !== undefined) { + Object.assign(where, organizationWhere(filter.organizationId)); + } + return where; } } diff --git a/packages/core/modules/repositories/module-repository-postgres.js b/packages/core/modules/repositories/module-repository-postgres.js index 55e6f8233..4eb78d08f 100644 --- a/packages/core/modules/repositories/module-repository-postgres.js +++ b/packages/core/modules/repositories/module-repository-postgres.js @@ -108,6 +108,25 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { return credentialMap; } + /** + * The one place a stored entity row becomes the entity the application sees. + * `organizationId` comes after the entity data: a provider field of the same + * name must not decide who sees the entity. + * @private + */ + _mapEntity(row, credential) { + return { + id: row.id.toString(), + credential, + userId: row.userId?.toString(), + name: row.name, + externalId: row.externalId, + moduleName: row.moduleName, + ...(row.data || {}), + organizationId: row.organizationId?.toString() ?? null, + }; + } + /** * Find entity by ID with credential * Replaces: Entity.findById(entityId).populate('credential') @@ -129,15 +148,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { const credential = await this._fetchCredential(entity.credentialId); - return { - id: entity.id.toString(), - credential, - userId: entity.userId?.toString(), - name: entity.name, - externalId: entity.externalId, - moduleName: entity.moduleName, - ...(entity.data || {}), - }; + return this._mapEntity(entity, credential); } /** @@ -157,15 +168,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { const credentialIds = entities.map(e => e.credentialId).filter(Boolean); const credentialMap = await this._fetchCredentialsBulk(credentialIds); - return entities.map((e) => ({ - id: e.id.toString(), - credential: credentialMap.get(e.credentialId) || null, - userId: e.userId?.toString(), - name: e.name, - externalId: e.externalId, - moduleName: e.moduleName, - ...(e.data || {}), - })); + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); } /** @@ -185,15 +188,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { const credentialIds = entities.map(e => e.credentialId).filter(Boolean); const credentialMap = await this._fetchCredentialsBulk(credentialIds); - return entities.map((e) => ({ - id: e.id.toString(), - credential: credentialMap.get(e.credentialId) || null, - userId: e.userId?.toString(), - name: e.name, - externalId: e.externalId, - moduleName: e.moduleName, - ...(e.data || {}), - })); + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); } /** @@ -217,15 +212,32 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { const credentialIds = entities.map(e => e.credentialId).filter(Boolean); const credentialMap = await this._fetchCredentialsBulk(credentialIds); - return entities.map((e) => ({ - id: e.id.toString(), - credential: credentialMap.get(e.credentialId) || null, - userId: e.userId?.toString(), - name: e.name, - externalId: e.externalId, - moduleName: e.moduleName, - ...(e.data || {}), - })); + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); + } + + /** + * Find entities matching any of the owner/organization pairs, in one query. + * `organizationId: null` matches rows with no organization. The caller + * (a use case) decides which pairs; this only runs them. + * + * @param {Array<{userId: string, organizationId: (string|null)}>} clauses + * @returns {Promise} Array of entity objects with string IDs + */ + async findEntitiesByOwnership(clauses) { + if (!Array.isArray(clauses) || clauses.length === 0) return []; + const entities = await this.prisma.entity.findMany({ + where: { + OR: clauses.map(({ userId, organizationId }) => ({ + userId: this._convertId(userId), + organizationId: this._convertId(organizationId), + })), + }, + }); + + const credentialIds = entities.map(e => e.credentialId).filter(Boolean); + const credentialMap = await this._fetchCredentialsBulk(credentialIds); + + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); } /** @@ -264,15 +276,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { const credential = await this._fetchCredential(entity.credentialId); - return { - id: entity.id.toString(), - credential, - userId: entity.userId?.toString(), - name: entity.name, - externalId: entity.externalId, - moduleName: entity.moduleName, - ...(entity.data || {}), - }; + return this._mapEntity(entity, credential); } /** @@ -292,15 +296,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { .filter(Boolean); const credentialMap = await this._fetchCredentialsBulk(credentialIds); - return entities.map((e) => ({ - id: e.id.toString(), - credential: credentialMap.get(e.credentialId) || null, - userId: e.userId?.toString(), - name: e.name, - externalId: e.externalId, - moduleName: e.moduleName, - ...(e.data || {}), - })); + return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null))); } /** @@ -308,9 +304,13 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { * Replaces: Entity.create(entityData) * * @param {Object} entityData - Entity data (with string IDs from application layer) + * @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity + * belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`, + * because `entityData` carries provider fields and one named `organizationId` + * must not be taken for ours. * @returns {Promise} Created entity object with string IDs */ - async createEntity(entityData) { + async createEntity(entityData, tenancy = {}) { const { user, userId, @@ -328,6 +328,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { name, moduleName, externalId, + organizationId: this._convertId(tenancy.organizationId ?? null), data: dynamicData, }; @@ -337,15 +338,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { const credentialObj = await this._fetchCredential(entity.credentialId); - return { - id: entity.id.toString(), - credential: credentialObj, - userId: entity.userId?.toString(), - name: entity.name, - externalId: entity.externalId, - moduleName: entity.moduleName, - ...(entity.data || {}), - }; + return this._mapEntity(entity, credentialObj); } /** @@ -401,15 +394,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { const credentialObj = await this._fetchCredential(entity.credentialId); - return { - id: entity.id.toString(), - credential: credentialObj, - userId: entity.userId?.toString(), - name: entity.name, - externalId: entity.externalId, - moduleName: entity.moduleName, - ...(entity.data || {}), - }; + return this._mapEntity(entity, credentialObj); } catch (error) { if (error.code === 'P2025') { return null; @@ -474,6 +459,11 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { if (filter.moduleName) where.moduleName = filter.moduleName; if (filter.externalId) where.externalId = this._toString(filter.externalId); + // `null` means "no organization"; absent means "any". + if (filter.organizationId !== undefined) { + where.organizationId = this._convertId(filter.organizationId); + } + return where; } } diff --git a/packages/core/modules/scope-config.js b/packages/core/modules/scope-config.js new file mode 100644 index 000000000..a5601bc10 --- /dev/null +++ b/packages/core/modules/scope-config.js @@ -0,0 +1,155 @@ +/** + * Reading and validating ownership settings in an app definition (ADR-056 + * §3.1, §3.3). Called once by loadAppDefinition, so a bad setting stops the app + * at load, not on the first request. No I/O. + */ + +const { + SCOPES, + INDIVIDUAL_ACROSS_VALUES, + OWNER_RULES, + configError, + isPlainObject, + parseScopeSetting, + resolveScope, +} = require('./scope-resolution'); +const { entityTypeNameOf } = require('./entity-type-registry'); + +/** The binding scope an integration declares for `modules[key]`, if any. */ +function bindingScopeSetting(Definition, key) { + const binding = Definition?.modules?.[key]; + if (binding?.scope !== undefined) return binding.scope; + const entityEntry = Definition?.entities?.[key]; + if (entityEntry?.scope !== undefined) return entityEntry.scope; + // Deprecated alias from the global-entities branch: `global: true`. + if (entityEntry?.global === true) return SCOPES.GLOBAL; + return undefined; +} + +function readAppOwnership(appDefinition) { + const raw = appDefinition?.ownership; + if (raw === undefined || raw === null) { + return { scope: null, individualAcross: 'organization' }; + } + if (!isPlainObject(raw)) { + throw configError('appDefinition.ownership', 'must be an object'); + } + const unknown = Object.keys(raw).filter( + (key) => key !== 'scope' && key !== 'individualAcross' + ); + if (unknown.length) { + throw configError( + 'appDefinition.ownership', + `unknown setting(s): ${unknown.join(', ')}` + ); + } + const individualAcross = raw.individualAcross ?? 'organization'; + if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) { + throw configError( + 'appDefinition.ownership.individualAcross', + `must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}` + ); + } + return { + scope: parseScopeSetting(raw.scope, 'appDefinition.ownership.scope'), + individualAcross, + }; +} + +function readIntegrationOwnership(integrationClass) { + const name = integrationClass?.Definition?.name || 'unnamed'; + const raw = integrationClass?.Definition?.ownership; + if (raw === undefined || raw === null) return { scope: null, owner: 'derive' }; + const where = `integration '${name}' ownership`; + if (!isPlainObject(raw)) throw configError(where, 'must be an object'); + const unknown = Object.keys(raw).filter( + (key) => key !== 'scope' && key !== 'owner' + ); + if (unknown.length) { + throw configError(where, `unknown setting(s): ${unknown.join(', ')}`); + } + const owner = raw.owner ?? 'derive'; + if (!OWNER_RULES.includes(owner)) { + throw configError(where, `owner must be one of ${OWNER_RULES.join(', ')}`); + } + return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner }; +} + +/** + * Every binding of every integration with its resolved scope and where it came + * from, parsing each integration's settings once. Throws OwnershipConfigError + * for any invalid one. Exported for tooling that wants to show the chain. + */ +function listBindingScopes(appDefinition, entityTypeRegistry, primary) { + const app = readAppOwnership(appDefinition); + const rows = []; + for (const integrationClass of appDefinition?.integrations || []) { + const Definition = integrationClass?.Definition; + const name = Definition?.name || 'unnamed'; + const ownership = readIntegrationOwnership(integrationClass); + for (const [key, binding] of Object.entries(Definition?.modules || {})) { + const moduleName = entityTypeNameOf(binding?.definition); + const resolved = resolveScope({ + binding: parseScopeSetting( + bindingScopeSetting(Definition, key), + `integration '${name}' binding '${key}' scope` + ), + integration: ownership.scope, + entityType: moduleName + ? entityTypeRegistry?.get(moduleName)?.settings?.scope + : undefined, + app: app.scope, + primary, + }); + rows.push({ + integration: name, + binding: key, + moduleName: moduleName ?? null, + scope: resolved.scope, + source: resolved.source, + owner: ownership.owner, + }); + } + } + return rows; +} + +/** + * Validates every ownership setting in one pass and returns the app-level + * config the runtime needs. + * + * @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string}} + * @throws {OwnershipConfigError} + */ +function resolveOwnershipConfig(appDefinition, entityTypeRegistry) { + const app = readAppOwnership(appDefinition); + for (const [name, entry] of entityTypeRegistry || []) { + parseScopeSetting(entry?.settings?.scope, `entity type '${name}' scope`); + } + const rows = listBindingScopes( + appDefinition, + entityTypeRegistry, + appDefinition?.user?.primary + ); + // §3.3: a shared integration must not run on one person's credential. + for (const row of rows) { + if (row.owner === 'organization' && row.scope === SCOPES.INDIVIDUAL) { + throw configError( + `integration '${row.integration}' binding '${row.binding}'`, + "an organization-owned integration cannot bind an 'individual' entity" + ); + } + } + return Object.freeze({ + scope: app.scope, + individualAcross: app.individualAcross, + }); +} + +module.exports = { + bindingScopeSetting, + readAppOwnership, + readIntegrationOwnership, + listBindingScopes, + resolveOwnershipConfig, +}; diff --git a/packages/core/modules/scope-policy.js b/packages/core/modules/scope-policy.js new file mode 100644 index 000000000..4754bde6b --- /dev/null +++ b/packages/core/modules/scope-policy.js @@ -0,0 +1,80 @@ +/** + * The object a use case asks about scopes (ADR-056 §3): the app's ownership + * config and entity type registry bound once, as closures. With no arguments it + * is the behaviour of an app that configures nothing. + */ + +const { + INDIVIDUAL_ACROSS_VALUES, + configError, + parseScopeSetting, + resolveScope, + resolveOwnerForScope, +} = require('./scope-resolution'); +const { accessContext, describeRecord } = require('./scope-visibility'); + +/** + * @param {Object} [params] + * @param {{scope: *, individualAcross: string}} [params.ownership] - the raw + * `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed + * `{ default, allow }` parses to itself) + * @param {Map} [params.entityTypeRegistry] + */ +function createScopePolicy({ ownership, entityTypeRegistry } = {}) { + const appScope = parseScopeSetting( + ownership?.scope, + 'appDefinition.ownership.scope' + ); + const individualAcross = ownership?.individualAcross ?? 'organization'; + if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) { + throw configError( + 'appDefinition.ownership.individualAcross', + `must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}` + ); + } + + const contextFor = (user) => accessContext(user, { individualAcross }); + + /** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */ + const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) => + resolveScope({ + request: requestScope, + binding, + integration, + entityType: entityTypeRegistry?.get?.(entityType)?.settings?.scope, + app: appScope, + primary: user?.config?.primary, + }); + + /** + * Scope, then owner and organization, for a new entity. `legacyOrgId` (the + * person's legacy organization pointer) rides along so a later lookup can + * reuse an entity written before scoping. + */ + const resolveOwnership = ({ entityType, requestScope, user }) => { + const ctx = contextFor(user); + const resolved = resolveEntityScope({ entityType, requestScope, user }); + return { + ...resolveOwnerForScope(resolved.scope, ctx), + legacyOrgId: ctx.legacyOrgId, + source: resolved.source, + }; + }; + + /** Whether `user` may load this stored entity for use (same clauses as the list). */ + const canUseEntity = (user, entity) => + describeRecord(entity, contextFor(user), 'entity') !== null; + + return Object.freeze({ + individualAcross, + accessContext: contextFor, + resolveScope: resolveEntityScope, + resolveOwnership, + canUseEntity, + }); +} + +/** The policy of an app that configures nothing; what a use case gets by default. */ +const DEFAULT_SCOPE_POLICY = createScopePolicy(); + +module.exports = { createScopePolicy, DEFAULT_SCOPE_POLICY }; diff --git a/packages/core/modules/scope-resolution.js b/packages/core/modules/scope-resolution.js new file mode 100644 index 000000000..a88ce771c --- /dev/null +++ b/packages/core/modules/scope-resolution.js @@ -0,0 +1,277 @@ +/** + * Scope resolution and ownership on create (ADR-056 §3.1, §3.2, §3.6). + * + * No I/O: everything here is a function of what it is handed (settings, a + * request's identity context, stored rows). This file also holds the shared + * constants and `OwnershipConfigError`, so `scope-config.js` (which validates + * app definitions) and `scope-visibility.js` can import them without a cycle. + * + * There is no `scope` column. The owner's user type already says it: no owner + * is `global`, the active organization's user is `organization`, the caller's + * individual user is `individual` (docs/guides/entity-scopes.md). + */ + +const Boom = require('@hapi/boom'); +const { BaseError } = require('../errors/base-error'); +const { isPresent, sameId } = require('../user/membership'); + +const SCOPES = Object.freeze({ + INDIVIDUAL: 'individual', + ORGANIZATION: 'organization', + GLOBAL: 'global', +}); +const SCOPE_VALUES = Object.freeze(Object.values(SCOPES)); +const INDIVIDUAL_ACROSS_VALUES = Object.freeze(['organization', 'all']); +const OWNER_RULES = Object.freeze(['derive', 'individual', 'organization']); +const ACCESS = Object.freeze({ MANAGE: 'manage', USE: 'use', VIEW: 'view' }); + +/** Raised at app-definition load for an invalid ownership setting. */ +class OwnershipConfigError extends BaseError { + constructor(message, options) { + super(message, options); + this.code = 'OWNERSHIP_CONFIG_INVALID'; + } +} + +const isPlainObject = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const configError = (where, message) => + new OwnershipConfigError(`${where}: ${message}`); + +/** + * One level's setting: a scope (`'organization'`) or a default plus what a + * request may choose (`{ default, allow }`). Returns `{ default, allow }`, or + * null when the level sets nothing. Idempotent on its own output. + */ +function parseScopeSetting(setting, where) { + if (setting === undefined || setting === null) return null; + if (typeof setting === 'string') { + if (!SCOPE_VALUES.includes(setting)) { + throw configError( + where, + `scope '${setting}' is not one of ${SCOPE_VALUES.join(', ')}` + ); + } + return { default: setting, allow: [setting] }; + } + if (!isPlainObject(setting)) { + throw configError(where, 'scope must be a scope name or { default, allow }'); + } + const unknown = Object.keys(setting).filter( + (key) => key !== 'default' && key !== 'allow' + ); + if (unknown.length) { + throw configError(where, `unknown scope setting(s): ${unknown.join(', ')}`); + } + const { allow, default: defaultScope } = setting; + if (allow !== undefined) { + if ( + !Array.isArray(allow) || + allow.length === 0 || + allow.some((value) => !SCOPE_VALUES.includes(value)) || + new Set(allow).size !== allow.length + ) { + throw configError( + where, + `scope.allow must list one or more of ${SCOPE_VALUES.join(', ')} once each` + ); + } + } + if (defaultScope !== undefined && !SCOPE_VALUES.includes(defaultScope)) { + throw configError( + where, + `scope.default '${defaultScope}' is not one of ${SCOPE_VALUES.join(', ')}` + ); + } + if (defaultScope === undefined && (allow === undefined || allow.length !== 1)) { + throw configError(where, 'scope needs a default (or an allow list of exactly one)'); + } + const resolvedDefault = defaultScope ?? allow[0]; + const resolvedAllow = allow ?? [resolvedDefault]; + if (!resolvedAllow.includes(resolvedDefault)) { + throw configError( + where, + `scope.default '${resolvedDefault}' must be in scope.allow` + ); + } + return { default: resolvedDefault, allow: [...resolvedAllow] }; +} + +/** The level-6 default: `organization` for an organization-primary app. */ +const defaultScopeFor = (primary) => + primary === 'organization' ? SCOPES.ORGANIZATION : SCOPES.INDIVIDUAL; + +const LEVELS = ['binding', 'integration', 'entityType', 'app']; + +/** + * The first level that sets a scope (ADR-056 §3.1), each overriding those + * below it; nothing intersects. Returns `{ default, allow, source }`. + */ +function decideLevel({ binding, integration, entityType, app, primary } = {}) { + const settings = { binding, integration, entityType, app }; + for (const source of LEVELS) { + const parsed = parseScopeSetting(settings[source], `${source} scope`); + if (parsed) return { ...parsed, source }; + } + const fallback = defaultScopeFor(primary); + return { default: fallback, allow: [fallback], source: 'default' }; +} + +/** + * What a request may choose, from the deciding level's `allow` list only. A + * tenant request is never given `global`; a scope outside the list is refused. + * Both are the same refusal (403 SCOPE_NOT_ALLOWED): the caller asked for + * something this app does not offer. + */ +function applyRequest(decided, request) { + const result = { + scope: decided.default, + source: decided.source, + decidedBy: decided.source, + allow: decided.allow, + }; + if (request === undefined || request === null) return result; + if (!SCOPE_VALUES.includes(request)) { + throw Boom.badRequest( + `scope must be one of ${SCOPE_VALUES.filter((s) => s !== SCOPES.GLOBAL).join(', ')}`, + { code: 'SCOPE_INVALID', details: { field: 'scope' } } + ); + } + if (request === SCOPES.GLOBAL) { + throw Boom.forbidden( + 'global entities are created by the app owner through admin routes, not by a tenant request', + { code: 'SCOPE_NOT_ALLOWED' } + ); + } + if (!decided.allow.includes(request)) { + throw Boom.forbidden( + `scope '${request}' is not allowed here; allowed: ${decided.allow.join(', ')}`, + { + code: 'SCOPE_NOT_ALLOWED', + details: { field: 'scope', allowed: decided.allow }, + } + ); + } + return { ...result, scope: request, source: 'request' }; +} + +/** + * The scope a new connection gets: decideLevel then applyRequest. + * + * @param {Object} levels + * @param {string} [levels.request] - `scope` on the request + * @param {*} [levels.binding] - `use(module, { scope })` / `Definition.entities[key].scope` + * @param {*} [levels.integration] - `Definition.ownership.scope` + * @param {*} [levels.entityType] - registry `settings.scope` (ADR-059) + * @param {*} [levels.app] - `appDefinition.ownership.scope` + * @param {string} [levels.primary] - `user.primary` + * @returns {{scope: string, source: string, decidedBy: string, allow: string[]}} + */ +function resolveScope({ request, ...levels } = {}) { + return applyRequest(decideLevel(levels), request); +} + +const MISSING_INDIVIDUAL = + 'this request has no individual user; send x-frigg-appuserid (or sign in with a token that names the person)'; +const MISSING_ORGANIZATION = + 'this request has no active organization; send x-frigg-apporgid (or sign in with a token that names the organization)'; + +const missing = (message, field) => + Boom.badRequest(message, { code: 'IDENTITY_REQUIRED', details: { field } }); + +/** + * The owner and organization for a new entity of `scope` (§3.2): + * + * individual -> the person; the active organization (null under 'all') + * organization -> the active organization's user, in that organization + * global -> refused here: the tenant API never creates global entities + * + * `ctx` is an access context (scope-visibility.accessContext). + */ +function resolveOwnerForScope(scope, ctx) { + if (scope === SCOPES.INDIVIDUAL) { + if (!ctx.callerId) throw missing(MISSING_INDIVIDUAL, 'x-frigg-appuserid'); + return { + scope, + ownerId: ctx.callerId, + organizationId: ctx.individualAcross === 'all' ? null : ctx.activeOrgId, + }; + } + if (scope === SCOPES.ORGANIZATION) { + if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid'); + return { scope, ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId }; + } + throw Boom.forbidden( + 'global entities are created by the app owner through admin routes, not through the tenant API', + { code: 'SCOPE_NOT_ALLOWED' } + ); +} + +/** + * The owner kind of an integration (§3 "Integration ownership"): the + * organization when every entity it uses is organization-scoped, otherwise the + * person, unless `Definition.ownership.owner` says. ResolveIntegrationOwnership + * then refuses an integration whose entities are not all of that one scope. + */ +function integrationOwnerKind(entityScopes, rule = 'derive') { + if (rule === 'individual' || rule === 'organization') return rule; + return entityScopes.length > 0 && + entityScopes.every((scope) => scope === SCOPES.ORGANIZATION) + ? SCOPES.ORGANIZATION + : SCOPES.INDIVIDUAL; +} + +/** + * The owner and organization of a new integration of owner kind `kind` + * ('individual' | 'organization'). + */ +function resolveIntegrationOwner(kind, ctx) { + if (kind === SCOPES.ORGANIZATION) { + if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid'); + return { ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId }; + } + if (!ctx.callerId) throw missing(MISSING_INDIVIDUAL, 'x-frigg-appuserid'); + return { ownerId: ctx.callerId, organizationId: ctx.activeOrgId }; +} + +/** + * The entity an authorization should reuse. The same provider account in two + * organizations is two entities (§3.6), so an exact organization match wins. + * A legacy row (organizationId null, written before scoping) is reused when + * the visibility rule would show it here: an organization-owned one always + * (the owner is unambiguous), an individual one only in the person's legacy + * organization. + */ +function pickExistingEntity(candidates, { scope, organizationId, legacyOrgId }) { + const rows = candidates || []; + const unscoped = (row) => !isPresent(row.organizationId); + const exact = rows.find((row) => + isPresent(organizationId) + ? sameId(row.organizationId, organizationId) + : unscoped(row) + ); + if (exact) return exact; + const legacyApplies = + scope === SCOPES.ORGANIZATION || sameId(legacyOrgId, organizationId); + return legacyApplies ? rows.find(unscoped) ?? null : null; +} + +module.exports = { + SCOPES, + SCOPE_VALUES, + INDIVIDUAL_ACROSS_VALUES, + OWNER_RULES, + ACCESS, + OwnershipConfigError, + configError, + isPlainObject, + parseScopeSetting, + decideLevel, + applyRequest, + resolveScope, + resolveOwnerForScope, + integrationOwnerKind, + resolveIntegrationOwner, + pickExistingEntity, +}; diff --git a/packages/core/modules/scope-visibility.js b/packages/core/modules/scope-visibility.js new file mode 100644 index 000000000..6036b9b2f --- /dev/null +++ b/packages/core/modules/scope-visibility.js @@ -0,0 +1,165 @@ +/** + * Which stored rows a caller may see and use (ADR-056 §3.4, §3.6). + * + * No I/O. One clause list (`ctx.clauses`) is what a repository is asked for and + * what `describeRecord` checks every returned row against, so a list and an + * access check cannot drift apart. The clauses are built once per request, when + * the access context is. + */ + +const { isPresent, idString, sameId } = require('../user/membership'); +const { SCOPES, ACCESS } = require('./scope-resolution'); + +/** + * One owner/organization pair a repository ORs into a single query. + * `userId` is the owner's user id, and for an organization-owned row that is + * the ORGANIZATION user's id, not a person's. `organizationId: null` means "has + * no organization" (unset or null); otherwise the id of the organization. + * + * @typedef {Object} OwnershipClause + * @property {string} userId + * @property {(string|null)} organizationId + */ + +/** + * Whether the caller's own rows with no organization belong to the context this + * request acts in: always when it acts in no organization (today's behaviour), + * always for an entity under `individualAcross: 'all'`, and otherwise only in + * the person's legacy organization. + */ +function unscopedRowsBelongHere(base, kind) { + return ( + !base.activeOrgId || + (kind === 'entity' && base.individualAcross === 'all') || + sameId(base.legacyOrgId, base.activeOrgId) + ); +} + +/** + * What a caller may see, as owner/organization pairs: + * + * - the active organization's own rows (owner = the organization), whether + * they carry its id or, written before scoping, none; + * - the caller's rows in the active organization; + * - the caller's rows with no organization, when `unscopedRowsBelongHere`. + * + * `kind` is 'entity' or 'integration'; only entities may be + * organization-independent under 'all' (an integration always belongs to the + * organization it was created in). + * + * @returns {OwnershipClause[]} + */ +function buildClauses(base, kind) { + const clauses = []; + if (base.activeOrgId) { + clauses.push({ userId: base.activeOrgId, organizationId: base.activeOrgId }); + clauses.push({ userId: base.activeOrgId, organizationId: null }); + } + if (base.callerId) { + if (base.activeOrgId) { + clauses.push({ userId: base.callerId, organizationId: base.activeOrgId }); + } + if (unscopedRowsBelongHere(base, kind)) { + clauses.push({ userId: base.callerId, organizationId: null }); + } + } + return clauses; +} + +/** + * Who the caller is, for ownership and visibility: their individual user, the + * organization this request acts in, the legacy `organizationId` pointer, and + * the clauses that follow from them (computed once). + * + * @param {Object} user - the authenticated `User` (or a `{ getId }` double) + * @param {{individualAcross?: string}} [options] + */ +function accessContext(user, { individualAcross = 'organization' } = {}) { + const hasUserModel = typeof user?.getIndividualUser === 'function'; + const individual = hasUserModel ? user.getIndividualUser() : null; + const base = { + callerId: hasUserModel ? idString(individual?.id) : idString(user?.getId?.()), + activeOrgId: + typeof user?.getActiveOrganizationId === 'function' + ? idString(user.getActiveOrganizationId()) + : null, + legacyOrgId: idString(individual?.organizationId), + individualAcross, + }; + return { + ...base, + clauses: { + entity: buildClauses(base, 'entity'), + integration: buildClauses(base, 'integration'), + }, + }; +} + +/** The clauses for `kind` ('entity' | 'integration'): what to ask the repository. */ +const visibilityClauses = (ctx, kind) => ctx.clauses[kind]; + +const matchesClause = (record, clause) => + sameId(record.userId, clause.userId) && + (clause.organizationId === null + ? !isPresent(record.organizationId) + : sameId(record.organizationId, clause.organizationId)); + +/** The scope a stored row has for this caller, from its owner. */ +function scopeOf(record, ctx) { + if (!isPresent(record?.userId)) return SCOPES.GLOBAL; + if (sameId(record.userId, ctx.activeOrgId)) return SCOPES.ORGANIZATION; + if (sameId(record.userId, ctx.callerId)) return SCOPES.INDIVIDUAL; + return null; +} + +const accessFor = (scope) => + scope === SCOPES.GLOBAL ? ACCESS.USE : ACCESS.MANAGE; + +/** + * What a row is to this caller, or null when they may not see it. + * `{ scope, owner: {type, id}, organizationId, access }` (§3.4). A `global` + * row is never described: it is not the tenant's to see. + */ +function describeRecord(record, ctx, kind) { + if (!record) return null; + const scope = scopeOf(record, ctx); + if (scope === null || scope === SCOPES.GLOBAL) return null; + if (!ctx.clauses[kind].some((clause) => matchesClause(record, clause))) { + return null; + } + return { + scope, + owner: { type: scope, id: idString(record.userId) }, + organizationId: + scope === SCOPES.ORGANIZATION + ? idString(record.userId) + : idString(record.organizationId), + access: accessFor(scope), + }; +} + +/** `?owner=` values and the scope each one means. */ +const LIST_OWNERS = Object.freeze({ + me: SCOPES.INDIVIDUAL, + org: SCOPES.ORGANIZATION, +}); + +/** Whether a described row passes `?scope=` and `?owner=`. */ +function matchesListFilters(description, { scope, owner } = {}) { + if (scope !== undefined && description.scope !== scope) return false; + if (owner !== undefined && description.owner.type !== LIST_OWNERS[owner]) { + return false; + } + return true; +} + +module.exports = { + accessContext, + unscopedRowsBelongHere, + visibilityClauses, + matchesClause, + scopeOf, + describeRecord, + LIST_OWNERS, + matchesListFilters, +}; diff --git a/packages/core/modules/tests/doubles/ownership-matching.js b/packages/core/modules/tests/doubles/ownership-matching.js new file mode 100644 index 000000000..210f6b1ff --- /dev/null +++ b/packages/core/modules/tests/doubles/ownership-matching.js @@ -0,0 +1,18 @@ +/** + * Test double helper: answers an owner/organization clause the way a database + * does, so in-memory repositories run the clauses a use case hands them. + * `clause.organizationId === null` matches a row with no organization (unset + * or null). + */ +const same = (a, b) => + a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b); + +const matchesClause = (row, clause) => + same(row.userId, clause.userId) && + (clause.organizationId === null + ? row.organizationId === null || row.organizationId === undefined + : same(row.organizationId, clause.organizationId)); + +const matchesAnyClause = (row, clauses) => clauses.some((clause) => matchesClause(row, clause)); + +module.exports = { same, matchesClause, matchesAnyClause }; diff --git a/packages/core/modules/use-cases/__tests__/authorization-ownership.test.js b/packages/core/modules/use-cases/__tests__/authorization-ownership.test.js new file mode 100644 index 000000000..57d0e9355 --- /dev/null +++ b/packages/core/modules/use-cases/__tests__/authorization-ownership.test.js @@ -0,0 +1,248 @@ +/** + * Who owns an entity an authorization creates, and which organization it is + * paired with (ADR-056 §3.2, §3.6). The module is the only fake; the scope + * policy, SubmitAuthorizationStep and ProcessAuthorizationCallback are real. + */ +jest.mock('../../module', () => ({ + Module: jest.fn().mockImplementation(({ userId, definition, entity }) => ({ + userId, + entity, + credential: undefined, + definition, + apiClass: { requesterType: 'apiKey' }, + api: { delegate: null }, + testAuth: jest.fn().mockResolvedValue(true), + apiParamsFromCredential: jest.fn().mockReturnValue({}), + apiParamsFromEntity: jest.fn().mockReturnValue({}), + getName: jest.fn().mockReturnValue('acme'), + })), +})); + +const { User } = require('../../../user/user'); +const { ProcessAuthorizationCallback } = require('../process-authorization-callback'); +const { SubmitAuthorizationStep } = require('../multi-step-authorization'); +const { AuthorizeEntityForUser } = require('../authorize-entity-for-user'); +const { createScopePolicy } = require('../../scope-policy'); +const { + InMemoryAuthorizationSessionRepository, +} = require('../../tests/doubles/in-memory-authorization-session-repository'); + +const caller = ({ individual = { id: '7', organizationId: null }, org = null, primary = 'individual' } = {}) => + new User(individual, org ? { id: String(org) } : null, false, primary, true, false); + +function build({ existing = [], registry, ownership } = {}) { + const calls = { getEntityDetailsUserId: [], credentialUserId: [] }; + const moduleDefinitions = [ + { + moduleName: 'acme', + modelName: 'Acme', + API: class {}, + requiredAuthMethods: { + setAuthParams: jest.fn().mockResolvedValue({}), + getCredentialDetails: jest.fn(async (_api, userId) => { + calls.credentialUserId.push(userId); + return { identifiers: { user: userId, externalId: 'cred-ext' }, details: { api_key: 's' } }; + }), + getEntityDetails: jest.fn(async (_api, _params, _token, userId) => { + calls.getEntityDetailsUserId.push(userId); + return { + identifiers: { user: userId, externalId: 'acct-1' }, + // A provider field that happens to be called organizationId. + details: { name: 'Acme', organizationId: 'provider-org' }, + }; + }), + }, + }, + ]; + const moduleRepository = { + findEntitiesByUserIdAndModuleName: jest.fn(async () => existing), + findEntities: jest.fn(async () => existing), + createEntity: jest.fn(async (data, tenancy) => ({ id: 'new', ...data, tenancy })), + updateEntity: jest.fn(), + findEntityById: jest.fn(async (id) => ({ id, userId: 'owner', moduleName: 'acme' })), + }; + const credentialRepository = { + upsertCredential: jest.fn(async () => ({ id: 'cred-1', authIsValid: true })), + findCredentialById: jest.fn(async (id) => ({ id })), + }; + const processAuthorizationCallback = new ProcessAuthorizationCallback({ + moduleRepository, + credentialRepository, + moduleDefinitions, + }); + const scopePolicy = createScopePolicy({ ownership, entityTypeRegistry: registry }); + const submit = new SubmitAuthorizationStep({ + moduleDefinitions, + authorizationSessionRepository: new InMemoryAuthorizationSessionRepository(), + getAuthorizationRequirements: {}, + authorizeEntityForUser: new AuthorizeEntityForUser({ scopePolicy, processAuthorizationCallback }), + moduleRepository, + credentialRepository, + scopePolicy, + }); + return { submit, moduleRepository, calls, processAuthorizationCallback }; +} + +const authorize = (t, user, extra = {}) => + t.submit.execute({ user, entityType: 'acme', data: { api_key: 'k' }, ...extra }); + +describe('authorizing an individual entity', () => { + it('owns it by the person and pairs it with the active organization', async () => { + const t = build(); + await authorize(t, caller({ org: 17 })); + const [data, tenancy] = t.moduleRepository.createEntity.mock.calls[0]; + expect(data.user).toBe('7'); + expect(tenancy).toEqual({ organizationId: '17' }); + // The module saw the owner, and the credential is the owner's. + expect(t.calls.getEntityDetailsUserId).toEqual(['7']); + expect(t.calls.credentialUserId).toEqual(['7']); + }); + + it('does not take a provider field called organizationId for the organization', async () => { + const t = build(); + await authorize(t, caller({ org: 17 })); + const [data, tenancy] = t.moduleRepository.createEntity.mock.calls[0]; + expect(data.organizationId).toBe('provider-org'); // stays provider data + expect(tenancy.organizationId).toBe('17'); // ours + }); + + it('with no active organization: paired with none', async () => { + const t = build(); + await authorize(t, caller()); + expect(t.moduleRepository.createEntity.mock.calls[0][1]).toEqual({ organizationId: null }); + }); + + it("under individualAcross 'all': stored with no organization, even inside one", async () => { + const t = build({ ownership: { individualAcross: 'all' } }); + await authorize(t, caller({ org: 17 })); + expect(t.moduleRepository.createEntity.mock.calls[0][1]).toEqual({ organizationId: null }); + }); + + it('in an organization-primary app, still owned by the person when the type says individual', async () => { + const registry = new Map([['acme', { settings: { scope: 'individual' } }]]); + const t = build({ registry }); + await authorize(t, caller({ org: 17, primary: 'organization' })); + const [data, tenancy] = t.moduleRepository.createEntity.mock.calls[0]; + expect(data.user).toBe('7'); + expect(tenancy.organizationId).toBe('17'); + }); +}); + +describe('authorizing an organization entity', () => { + const registry = new Map([['acme', { settings: { scope: 'organization' } }]]); + + it('owns it by the active organization user, in that organization', async () => { + const t = build({ registry }); + await authorize(t, caller({ org: 17 })); + const [data, tenancy] = t.moduleRepository.createEntity.mock.calls[0]; + expect(data.user).toBe('17'); + expect(tenancy).toEqual({ organizationId: '17' }); + expect(t.calls.credentialUserId).toEqual(['17']); + }); + + it('is the default for an organization-primary app that sets nothing', async () => { + const t = build(); + await authorize(t, caller({ org: 17, primary: 'organization' })); + expect(t.moduleRepository.createEntity.mock.calls[0][0].user).toBe('17'); + }); + + it('400s with no active organization, and does not create anything', async () => { + const t = build({ registry }); + await expect(authorize(t, caller())).rejects.toMatchObject({ + output: { statusCode: 400 }, + data: { code: 'IDENTITY_REQUIRED', details: { field: 'x-frigg-apporgid' } }, + }); + expect(t.moduleRepository.createEntity).not.toHaveBeenCalled(); + }); +}); + +describe('the scope a request may choose', () => { + const registry = new Map([ + ['acme', { settings: { scope: { default: 'organization', allow: ['organization', 'individual'] } } }], + ]); + + it('takes the request scope when the type allows it', async () => { + const t = build({ registry }); + await authorize(t, caller({ org: 17 }), { scope: 'individual' }); + expect(t.moduleRepository.createEntity.mock.calls[0][0].user).toBe('7'); + }); + + it('refuses a scope the type does not allow, and global always', async () => { + const strict = build({ registry: new Map([['acme', { settings: { scope: 'individual' } }]]) }); + await expect(authorize(strict, caller({ org: 17 }), { scope: 'organization' })).rejects.toMatchObject({ + output: { statusCode: 403 }, + data: { code: 'SCOPE_NOT_ALLOWED' }, + }); + const t = build({ registry }); + await expect(authorize(t, caller({ org: 17 }), { scope: 'global' })).rejects.toMatchObject({ + output: { statusCode: 403 }, + data: { code: 'SCOPE_NOT_ALLOWED' }, + }); + expect(t.moduleRepository.createEntity).not.toHaveBeenCalled(); + }); + + it('a global type cannot be created through the tenant API: refused with a clear error', async () => { + const t = build({ registry: new Map([['acme', { settings: { scope: 'global' } }]]) }); + await expect(authorize(t, caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 403 }, + message: expect.stringMatching(/app owner through admin routes/), + }); + expect(t.moduleRepository.createEntity).not.toHaveBeenCalled(); + }); +}); + +describe('re-authorizing finds the entity of the same organization', () => { + const existingIn = (organizationId, id = 'old') => ({ + id, + userId: '7', + organizationId, + moduleName: 'acme', + externalId: 'acct-1', + credential: { id: 'cred-1' }, + }); + + it('reuses the entity connected in this organization, and not the one from another', async () => { + const t = build({ existing: [existingIn('18', 'in18'), existingIn('17', 'in17')] }); + const result = await authorize(t, caller({ org: 17 })); + expect(t.moduleRepository.createEntity).not.toHaveBeenCalled(); + expect(result.entity.id).toBe('in17'); + }); + + it('creates a new entity when the account was only connected in another organization', async () => { + const t = build({ existing: [existingIn('18')] }); + await authorize(t, caller({ org: 17 })); + expect(t.moduleRepository.createEntity).toHaveBeenCalledTimes(1); + }); + + it('reuses a legacy unscoped entity in the legacy organization of the person', async () => { + const t = build({ existing: [existingIn(null)] }); + const result = await authorize(t, caller({ individual: { id: '7', organizationId: '17' }, org: 17 })); + expect(t.moduleRepository.createEntity).not.toHaveBeenCalled(); + expect(result.entity.id).toBe('old'); + }); + + it('does not reuse a legacy unscoped entity in a second organization', async () => { + const t = build({ existing: [existingIn(null)] }); + await authorize(t, caller({ individual: { id: '7', organizationId: '99' }, org: 17 })); + expect(t.moduleRepository.createEntity).toHaveBeenCalledTimes(1); + }); + + it('asks the repository for the owner and the account; the organization is picked from the rows', async () => { + const t = build({ existing: [] }); + await authorize(t, caller({ org: 17 })); + expect(t.moduleRepository.findEntities).toHaveBeenCalledWith({ + externalId: 'acct-1', + user: '7', + moduleName: 'acme', + }); + }); +}); + +describe('a callback with no tenancy means the person own, outside any organization', () => { + it('looks up and creates with the default tenancy', async () => { + const t = build(); + await t.processAuthorizationCallback.execute('u1', 'acme', { api_key: 'k' }); + expect(t.moduleRepository.findEntities).toHaveBeenCalled(); + expect(t.moduleRepository.createEntity.mock.calls[0][1]).toEqual({ organizationId: null }); + }); +}); diff --git a/packages/core/modules/use-cases/__tests__/authorize-entity-for-user.test.js b/packages/core/modules/use-cases/__tests__/authorize-entity-for-user.test.js new file mode 100644 index 000000000..799c42045 --- /dev/null +++ b/packages/core/modules/use-cases/__tests__/authorize-entity-for-user.test.js @@ -0,0 +1,62 @@ +const { AuthorizeEntityForUser } = require('../authorize-entity-for-user'); +const { createScopePolicy } = require('../../scope-policy'); + +const user = (orgId) => ({ + config: { primary: 'individual' }, + getId: () => '7', + getIndividualUser: () => ({ id: '7', organizationId: '99' }), + getActiveOrganizationId: () => orgId, +}); + +const build = (registry) => { + const processAuthorizationCallback = { + execute: jest.fn(async () => ({ credential_id: 'c', entity_id: 'e', type: 'acme' })), + }; + const useCase = new AuthorizeEntityForUser({ + scopePolicy: createScopePolicy({ entityTypeRegistry: registry }), + processAuthorizationCallback, + }); + return { useCase, processAuthorizationCallback }; +}; + +describe('AuthorizeEntityForUser', () => { + it('runs the authorization as the owner the scope decides, with the tenancy the callback needs', async () => { + const { useCase, processAuthorizationCallback } = build( + new Map([['acme', { settings: { scope: 'organization' } }]]) + ); + const { result, ownership } = await useCase.execute({ + user: user('17'), + entityType: 'acme', + data: { key: 'k' }, + }); + expect(result).toEqual({ credential_id: 'c', entity_id: 'e', type: 'acme' }); + expect(ownership).toMatchObject({ scope: 'organization', ownerId: '17', organizationId: '17' }); + expect(processAuthorizationCallback.execute).toHaveBeenCalledWith('17', 'acme', { key: 'k' }, { + scope: 'organization', + organizationId: '17', + legacyOrgId: '99', + }); + }); + + it('uses the ownership it is handed instead of resolving it again', async () => { + const { useCase, processAuthorizationCallback } = build(); + const ownership = { scope: 'individual', ownerId: '7', organizationId: '17', legacyOrgId: null }; + const resolve = jest.spyOn(useCase, 'resolveOwnership'); + await useCase.execute({ user: user('17'), entityType: 'acme', data: {}, ownership }); + expect(resolve).not.toHaveBeenCalled(); + expect(processAuthorizationCallback.execute.mock.calls[0][0]).toBe('7'); + }); + + it('does not call the module when the request cannot have an owner', async () => { + const { useCase, processAuthorizationCallback } = build( + new Map([['acme', { settings: { scope: 'organization' } }]]) + ); + await expect( + useCase.execute({ user: user(null), entityType: 'acme', data: {} }) + ).rejects.toMatchObject({ output: { statusCode: 400 } }); + await expect( + useCase.execute({ user: user('17'), entityType: 'acme', data: {}, scope: 'global' }) + ).rejects.toMatchObject({ output: { statusCode: 403 } }); + expect(processAuthorizationCallback.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/modules/use-cases/__tests__/entity-scope-use-cases.test.js b/packages/core/modules/use-cases/__tests__/entity-scope-use-cases.test.js new file mode 100644 index 000000000..fb31906e8 --- /dev/null +++ b/packages/core/modules/use-cases/__tests__/entity-scope-use-cases.test.js @@ -0,0 +1,281 @@ +/** + * The scope rules over in-memory repositories that answer the way a database + * does (they run the clauses they are handed), with the real `User`. Where a + * test needs the request shape it asserts the clauses sent, not the rows + * returned: a double that returns what the code wants cannot catch a query + * that does not filter. + */ +jest.mock('../../module', () => ({ + // Building a Module needs a real API definition; the rules under test run before it. + Module: jest.fn(function Module(params) { + Object.assign(this, params); + }), +})); +jest.mock('../../utils/map-module-dto', () => ({ + mapModuleClassToModuleDTO: (module) => ({ id: module.entity.id }), +})); + +const { User } = require('../../../user/user'); +const { matchesAnyClause } = require('../../tests/doubles/ownership-matching'); +const { GetEntityModuleForUser } = require('../get-entity-module-for-user'); +const { + ListEntitiesForUser, + GetEntityForUser, + DeleteEntityForUser, +} = require('../entity-operations-for-user'); +const { GetModule } = require('../get-module'); +const { GetEntitiesForUser } = require('../get-entities-for-user'); +const { createScopePolicy } = require('../../scope-policy'); + +function fakeRepos({ entities = [], integrations = [] } = {}) { + const moduleRepository = { + findEntitiesByOwnership: jest.fn(async (clauses) => + entities.filter((row) => matchesAnyClause(row, clauses)) + ), + findEntityById: jest.fn(async (id) => { + const row = entities.find((e) => String(e.id) === String(id)); + if (!row) throw new Error(`Entity ${id} not found`); + return row; + }), + deleteEntity: jest.fn(), + }; + const integrationRepository = { + findIntegrationsByEntityId: jest.fn(async () => integrations), + }; + return { moduleRepository, integrationRepository }; +} + +// Person 7 belongs to organizations 17 (the legacy pointer) and 18. Person 8 is in 17. +const person = (id, pointer = null) => ({ id: String(id), type: 'INDIVIDUAL', organizationId: pointer }); +const orgUser = (id) => ({ id: String(id), type: 'ORGANIZATION' }); +const callerIn = (individual, org, { primary = 'individual' } = {}) => + new User(individual, org ? orgUser(org) : null, false, primary, true, false); + +const entity = (id, userId, organizationId, extra = {}) => ({ + id: String(id), + userId: String(userId), + organizationId: organizationId === null ? null : String(organizationId), + moduleName: 'acme', + credential: extra.credential ? { id: extra.credential } : null, + ...extra, +}); + +describe('entities are listed and loaded per active organization', () => { + const rows = [ + entity('g17', 7, 17), // 7's Google, connected in 17 + entity('g18', 7, 18), // the same person's Google, connected in 18 + entity('x17', 17, 17), // organization 17's own + entity('x18', 18, 18), // organization 18's own + entity('h17', 8, 17), // a colleague's, in 17 + entity('global', null, null), // the app owner's + ]; + const ids = (list) => list.map((e) => e.id).sort(); + + it('lists the active organization entities and the caller individual ones, never another organization', async () => { + const repos = fakeRepos({ entities: rows }); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + const inSeventeen = await list.execute(callerIn(person(7, 17), 17)); + expect(ids(inSeventeen)).toEqual(['g17', 'x17']); + const inEighteen = await list.execute(callerIn(person(7, 17), 18)); + expect(ids(inEighteen)).toEqual(['g18', 'x18']); + }); + + it('carries scope, owner, organizationId and access on every row', async () => { + const repos = fakeRepos({ entities: rows }); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + const result = await list.execute(callerIn(person(7, 17), 17)); + expect(Object.fromEntries(result.map((e) => [e.id, e.tenancy]))).toEqual({ + g17: { scope: 'individual', owner: { type: 'individual', id: '7' }, organizationId: '17', access: 'manage' }, + x17: { scope: 'organization', owner: { type: 'organization', id: '17' }, organizationId: '17', access: 'manage' }, + }); + }); + + it('sends the owner/organization pairs, not a bare user id', async () => { + const repos = fakeRepos({ entities: rows }); + await new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }).execute(callerIn(person(7, 17), 17)); + expect(repos.moduleRepository.findEntitiesByOwnership).toHaveBeenCalledTimes(1); + expect(repos.moduleRepository.findEntitiesByOwnership).toHaveBeenCalledWith([ + { userId: '17', organizationId: '17' }, + { userId: '17', organizationId: null }, + { userId: '7', organizationId: '17' }, + { userId: '7', organizationId: null }, // person 7's legacy pointer is 17 + ]); + }); + + it('does not widen the list if a repository returns more than it was asked for', async () => { + const repos = fakeRepos({ entities: rows }); + repos.moduleRepository.findEntitiesByOwnership = jest.fn(async () => rows); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + expect(ids(await list.execute(callerIn(person(7), 17)))).toEqual(['g17', 'x17']); + }); + + it('filters ?scope and ?owner', async () => { + const repos = fakeRepos({ entities: rows }); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + const caller = callerIn(person(7), 17); + expect(ids(await list.execute(caller, { scope: 'organization' }))).toEqual(['x17']); + expect(ids(await list.execute(caller, { owner: 'me' }))).toEqual(['g17']); + expect(ids(await list.execute(caller, { owner: 'org', scope: 'individual' }))).toEqual([]); + }); + + it("'all' lists the caller organization-independent entities in every organization", async () => { + const all = [entity('u7', 7, null), entity('u8', 8, null), entity('x17', 17, 17), entity('x18', 18, 18)]; + const repos = fakeRepos({ entities: all }); + const policy = createScopePolicy({ ownership: { individualAcross: 'all' } }); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository, scopePolicy: policy }); + expect(ids(await list.execute(callerIn(person(7), 17)))).toEqual(['u7', 'x17']); + expect(ids(await list.execute(callerIn(person(7), 18)))).toEqual(['u7', 'x18']); + // Without 'all' the same rows are not the caller's in either organization. + const strict = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + expect(ids(await strict.execute(callerIn(person(7), 17)))).toEqual(['x17']); + }); + + describe('loading one entity for use (proxy, test-auth, options)', () => { + const load = (id, caller, policy) => + new GetEntityModuleForUser({ + moduleRepository: fakeRepos({ entities: rows }).moduleRepository, + moduleDefinitions: [{ moduleName: 'acme' }], + scopePolicy: policy, + }).loadOwnedEntity(id, caller); + + it('loads the caller entity in the organization it was connected in', async () => { + expect((await load('g17', callerIn(person(7), 17))).id).toBe('g17'); + }); + + it('404s the same entity from another organization: connected in 17 is unusable in 18', async () => { + await expect(load('g17', callerIn(person(7), 18))).rejects.toMatchObject({ + output: { statusCode: 404 }, + data: { code: 'ENTITY_NOT_FOUND' }, + }); + }); + + it('404s a colleague entity, another organization entity and a global one', async () => { + for (const id of ['h17', 'x18', 'global']) { + await expect(load(id, callerIn(person(7), 17))).rejects.toMatchObject({ + output: { statusCode: 404 }, + }); + } + }); + + it('loads the organization entity for any member of it', async () => { + expect((await load('x17', callerIn(person(8), 17))).id).toBe('x17'); + await expect(load('x17', callerIn(person(8), 18))).rejects.toMatchObject({ output: { statusCode: 404 } }); + }); + + it("'all' makes an organization-independent entity usable in every organization", async () => { + const repos = fakeRepos({ entities: [entity('u7', 7, null)] }); + const loader = (caller, policy) => + new GetEntityModuleForUser({ + moduleRepository: repos.moduleRepository, + moduleDefinitions: [], + scopePolicy: policy, + }).loadOwnedEntity('u7', caller); + const all = createScopePolicy({ ownership: { individualAcross: 'all' } }); + expect((await loader(callerIn(person(7), 18), all)).id).toBe('u7'); + await expect(loader(callerIn(person(8), 18), all)).rejects.toMatchObject({ output: { statusCode: 404 } }); + await expect(loader(callerIn(person(7), 18))).rejects.toMatchObject({ output: { statusCode: 404 } }); + }); + + it('GetEntityForUser returns the tenancy beside the entity', async () => { + const getEntityModuleForUser = new GetEntityModuleForUser({ + moduleRepository: fakeRepos({ entities: rows }).moduleRepository, + moduleDefinitions: [], + }); + const found = await new GetEntityForUser({ getEntityModuleForUser }).execute('x17', callerIn(person(7), 17)); + expect(found.tenancy).toMatchObject({ scope: 'organization', access: 'manage' }); + }); + }); + + describe('legacy rows: an existing deployment keeps its entities', () => { + it('an app with no organizations sees every null-organization entity of the caller, as before', async () => { + const legacy = [entity('l1', 7, null), entity('l2', 7, null), entity('o', 8, null)]; + const repos = fakeRepos({ entities: legacy }); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + expect(ids(await list.execute(callerIn(person(7), null)))).toEqual(['l1', 'l2']); + }); + + it('an organization app: unscoped individual entities show in the legacy organization, not in a second one', async () => { + const legacy = [entity('l1', 7, null)]; + const repos = fakeRepos({ entities: legacy }); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + expect(ids(await list.execute(callerIn(person(7, 17), 17)))).toEqual(['l1']); + expect(ids(await list.execute(callerIn(person(7, 17), 18)))).toEqual([]); + }); + + it('organization-primary apps: the entity of the linked individual, the case ownsUserId was written for', async () => { + // Entity 6 owned by individual 4, integration owned by organization 13, 4 linked to 13. + const repos = fakeRepos({ entities: [entity('6', 4, null), entity('12', 13, null)] }); + const caller = callerIn(person(4, 13), 13, { primary: 'organization' }); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + expect(ids(await list.execute(caller))).toEqual(['12', '6']); + expect(caller.getId()).toBe('13'); + }); + + it('organization-owned unscoped entities are the organization own', async () => { + const repos = fakeRepos({ entities: [entity('12', 13, null)] }); + const list = new ListEntitiesForUser({ moduleRepository: repos.moduleRepository }); + const result = await list.execute(callerIn(person(4), 13)); + expect(result[0].tenancy).toMatchObject({ scope: 'organization', organizationId: '13' }); + }); + }); + + describe('deleting', () => { + const build = (rowsToUse) => { + const repos = fakeRepos({ entities: rowsToUse }); + const getEntityModuleForUser = new GetEntityModuleForUser({ + moduleRepository: repos.moduleRepository, + moduleDefinitions: [], + }); + return { + repos, + useCase: new DeleteEntityForUser({ + getEntityModuleForUser, + moduleRepository: repos.moduleRepository, + integrationRepository: repos.integrationRepository, + }), + }; + }; + + it('deletes an organization entity for a member of the organization', async () => { + const { repos, useCase } = build([entity('x17', 17, 17)]); + await useCase.execute('x17', callerIn(person(7), 17)); + expect(repos.moduleRepository.deleteEntity).toHaveBeenCalledWith('x17'); + }); + + it('404s deleting an entity of another organization', async () => { + const { repos, useCase } = build([entity('x18', 18, 18)]); + await expect(useCase.execute('x18', callerIn(person(7), 17))).rejects.toMatchObject({ output: { statusCode: 404 } }); + expect(repos.moduleRepository.deleteEntity).not.toHaveBeenCalled(); + }); + + it('deletes an entity the caller may use', async () => { + const { repos, useCase } = build([entity('g17', 7, 17)]); + await useCase.execute('g17', callerIn(person(7), 17)); + expect(repos.moduleRepository.deleteEntity).toHaveBeenCalledWith('g17'); + }); + }); +}); + +describe('v1 entity use cases follow the same rule', () => { + it('GetModule 404s... refuses the same entity from another organization', async () => { + const repos = fakeRepos({ entities: [entity('g17', 7, 17, { externalId: 'x', name: 'n' })] }); + const definition = { moduleName: 'acme', modelName: 'acme' }; + // Module construction is the caller's problem; the access check runs first. + const useCase = new GetModule({ moduleRepository: repos.moduleRepository, moduleDefinitions: [definition] }); + await expect(useCase.execute('g17', callerIn(person(7), 18))).rejects.toThrow(/does not belong/); + }); + + it('GetEntitiesForUser (v1 list) asks for the visible pairs', async () => { + const repos = fakeRepos({ entities: [entity('g17', 7, 17), entity('g18', 7, 18)] }); + const useCase = new GetEntitiesForUser({ + moduleRepository: repos.moduleRepository, + moduleDefinitions: [{ moduleName: 'acme' }], + }); + expect(await useCase.execute(callerIn(person(7), 17))).toEqual([{ id: 'g17' }]); + expect(repos.moduleRepository.findEntitiesByOwnership).toHaveBeenCalledWith([ + { userId: '17', organizationId: '17' }, + { userId: '17', organizationId: null }, + { userId: '7', organizationId: '17' }, + ]); + }); +}); diff --git a/packages/core/modules/use-cases/__tests__/process-authorization-callback-logs.test.js b/packages/core/modules/use-cases/__tests__/process-authorization-callback-logs.test.js index 42661c4d8..ed3b2a56a 100644 --- a/packages/core/modules/use-cases/__tests__/process-authorization-callback-logs.test.js +++ b/packages/core/modules/use-cases/__tests__/process-authorization-callback-logs.test.js @@ -61,10 +61,10 @@ describe('ProcessAuthorizationCallback logs (ADR-048)', () => { credential: { id: 'cred-1' }, }, ]), - findEntity: jest.fn().mockResolvedValue({ + findEntities: jest.fn().mockResolvedValue([{ id: 'entity-1', credential: { id: 'cred-1' }, - }), + }]), createEntity: jest.fn(), updateEntity: jest.fn(), }; @@ -236,10 +236,10 @@ describe('ProcessAuthorizationCallback logs (ADR-048)', () => { }); it('writes an INFO record when re-auth repoints an entity to a new credential', async () => { - moduleRepository.findEntity.mockResolvedValue({ + moduleRepository.findEntities.mockResolvedValue([{ id: 'entity-1', credential: { id: 'old-cred' }, - }); + }]); moduleRepository.updateEntity.mockResolvedValue({ id: 'entity-1' }); await useCase.execute('user-1', 'testmodule', { code: SECRETS.oauthCode }); diff --git a/packages/core/modules/use-cases/__tests__/process-authorization-callback.test.js b/packages/core/modules/use-cases/__tests__/process-authorization-callback.test.js index 4ba32e23f..0b0170f25 100644 --- a/packages/core/modules/use-cases/__tests__/process-authorization-callback.test.js +++ b/packages/core/modules/use-cases/__tests__/process-authorization-callback.test.js @@ -31,13 +31,15 @@ describe('ProcessAuthorizationCallback — re-auth status restoration', () => { integrationRepository = new TestIntegrationRepository(); moduleRepository = { - findEntity: jest.fn().mockResolvedValue({ - id: 'entity-1', - userId: 'user-1', - moduleName: 'testmodule', - externalId: 'ext-1', - credential: { id: 'cred-1' }, - }), + findEntities: jest.fn().mockResolvedValue([ + { + id: 'entity-1', + userId: 'user-1', + moduleName: 'testmodule', + externalId: 'ext-1', + credential: { id: 'cred-1' }, + }, + ]), findEntitiesByUserIdAndModuleName: jest.fn().mockResolvedValue([ { id: 'entity-1', diff --git a/packages/core/modules/use-cases/authorize-entity-for-user.js b/packages/core/modules/use-cases/authorize-entity-for-user.js new file mode 100644 index 000000000..d4d44f487 --- /dev/null +++ b/packages/core/modules/use-cases/authorize-entity-for-user.js @@ -0,0 +1,59 @@ +/** + * Authorizes an entity for a caller: decides who will own it and in which + * organization (ADR-056 §3.2, §3.6), then runs the module's authorization. + * Shared by the v1 `POST /api/authorize` and the v2 authorize flow, so the + * owner is decided in one place and never by `user.primary`. + */ +class AuthorizeEntityForUser { + /** + * @param {Object} params + * @param {Object} params.scopePolicy - from createScopePolicy + * @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback + */ + constructor({ scopePolicy, processAuthorizationCallback }) { + this.scopePolicy = scopePolicy; + this.processAuthorizationCallback = processAuthorizationCallback; + } + + /** + * The scope, owner and organization a new entity of `entityType` gets for + * this caller. Throws a 400/403 when the request cannot have one. + * + * @param {{user: Object, entityType: string, scope?: string}} params + */ + resolveOwnership({ user, entityType, scope }) { + return this.scopePolicy.resolveOwnership({ + entityType, + requestScope: scope, + user, + }); + } + + /** + * @param {Object} params + * @param {Object} params.user - authenticated User + * @param {string} params.entityType + * @param {Object} params.data - what the module's authorization needs + * @param {string} [params.scope] - the scope the request asks for + * @param {Object} [params.ownership] - from `resolveOwnership`, when the + * caller needed it earlier (a multi-step flow does) + * @returns {Promise<{result: Object, ownership: Object}>} the callback's + * `{ credential_id, entity_id, type }` and the ownership it ran with + */ + async execute({ user, entityType, data, scope, ownership }) { + const resolved = ownership ?? this.resolveOwnership({ user, entityType, scope }); + const result = await this.processAuthorizationCallback.execute( + resolved.ownerId, + entityType, + data, + { + scope: resolved.scope, + organizationId: resolved.organizationId, + legacyOrgId: resolved.legacyOrgId, + } + ); + return { result, ownership: resolved }; + } +} + +module.exports = { AuthorizeEntityForUser }; diff --git a/packages/core/modules/use-cases/entity-operations-for-user.js b/packages/core/modules/use-cases/entity-operations-for-user.js index 73d615e5c..91f3747ea 100644 --- a/packages/core/modules/use-cases/entity-operations-for-user.js +++ b/packages/core/modules/use-cases/entity-operations-for-user.js @@ -1,4 +1,10 @@ const Boom = require('@hapi/boom'); +const { + describeRecord, + visibilityClauses, + matchesListFilters, +} = require('../scope-visibility'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); /** * Management API v2 entity operations. Each one starts from @@ -6,29 +12,55 @@ const Boom = require('@hapi/boom'); * (missing or foreign entity = 404). */ -/** The caller's connected entities. */ +/** + * The entities the caller may see in the active organization (ADR-056 §3.4, + * §3.6): the organization's own, and the caller's individual ones by the + * visibility rule. Each carries `tenancy` ({scope, owner, organizationId, + * access}); the stored entity is otherwise untouched. + */ class ListEntitiesForUser { - constructor({ moduleRepository }) { + constructor({ moduleRepository, scopePolicy }) { this.moduleRepository = moduleRepository; + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; } - /** @param {Object} user - authenticated User */ - async execute(user) { - const entities = await this.moduleRepository.findEntitiesByUserId( - user.getId() + /** + * @param {Object} user - authenticated User + * @param {{scope?: string, owner?: string}} [filters] - `?scope=` and `?owner=` + */ + async execute(user, filters = {}) { + const ctx = this.scopePolicy.accessContext(user); + const entities = await this.moduleRepository.findEntitiesByOwnership( + visibilityClauses(ctx, 'entity') ); - return Array.isArray(entities) ? entities : []; + const listed = []; + for (const entity of Array.isArray(entities) ? entities : []) { + // The query is the filter; this is the same rule applied again, + // so a repository that returns too much cannot widen the list. + const tenancy = describeRecord(entity, ctx, 'entity'); + if (tenancy && matchesListFilters(tenancy, filters)) { + listed.push({ ...entity, tenancy }); + } + } + return listed; } } -/** One entity the caller owns. */ +/** One entity the caller may use, with its `tenancy`. */ class GetEntityForUser { constructor({ getEntityModuleForUser }) { this.getEntityModuleForUser = getEntityModuleForUser; } async execute(entityId, user) { - return this.getEntityModuleForUser.loadOwnedEntity(entityId, user); + const entity = await this.getEntityModuleForUser.loadOwnedEntity( + entityId, + user + ); + return { + ...entity, + tenancy: this.getEntityModuleForUser.describe(entity, user), + }; } } @@ -79,7 +111,7 @@ class RefreshEntityOptionsForUser { } /** - * Deletes an entity the caller owns. Refuses with 409 while integrations + * Deletes an entity the caller may use. Refuses with 409 while integrations * still use it, so an integration never points at a missing entity. The * credential is left alone (others may share it); delete it through * /api/v2/credentials. diff --git a/packages/core/modules/use-cases/entity-operations-for-user.test.js b/packages/core/modules/use-cases/entity-operations-for-user.test.js index 13eb2683d..129cafb7b 100644 --- a/packages/core/modules/use-cases/entity-operations-for-user.test.js +++ b/packages/core/modules/use-cases/entity-operations-for-user.test.js @@ -9,6 +9,12 @@ const { } = require('./entity-operations-for-user'); const user = { getId: () => 'u1' }; +const ownerTenancy = { + scope: 'individual', + owner: { type: 'individual', id: 'u1' }, + organizationId: null, + access: 'manage', +}; const entity = { id: 'e1', userId: 'u1', moduleName: 'acme', credential: { id: 'c1' } }; function setup({ module: moduleOverrides = {}, integrations = [] } = {}) { @@ -20,7 +26,7 @@ function setup({ module: moduleOverrides = {}, integrations = [] } = {}) { }; const moduleRepository = { findEntityById: jest.fn(async (id) => (id === 'e1' ? entity : null)), - findEntitiesByUserId: jest.fn().mockResolvedValue([entity]), + findEntitiesByOwnership: jest.fn().mockResolvedValue([entity]), deleteEntity: jest.fn().mockResolvedValue(true), }; const integrationRepository = { @@ -37,14 +43,19 @@ function setup({ module: moduleOverrides = {}, integrations = [] } = {}) { describe('v2 entity operations', () => { it('lists the caller entities', async () => { const { moduleRepository } = setup(); - expect(await new ListEntitiesForUser({ moduleRepository }).execute(user)).toEqual([entity]); - expect(moduleRepository.findEntitiesByUserId).toHaveBeenCalledWith('u1'); + expect(await new ListEntitiesForUser({ moduleRepository }).execute(user)).toEqual([ + { ...entity, tenancy: ownerTenancy }, + ]); + // One query for the caller's owner/organization pairs: no organization here. + expect(moduleRepository.findEntitiesByOwnership).toHaveBeenCalledWith([ + { userId: 'u1', organizationId: null }, + ]); }); it('gets an owned entity and 404s otherwise', async () => { const deps = setup(); const useCase = new GetEntityForUser(deps); - expect(await useCase.execute('e1', user)).toBe(entity); + expect(await useCase.execute('e1', user)).toEqual({ ...entity, tenancy: ownerTenancy }); await expect(useCase.execute('e2', user)).rejects.toMatchObject({ output: { statusCode: 404 }, }); diff --git a/packages/core/modules/use-cases/entity-types.js b/packages/core/modules/use-cases/entity-types.js index 2175f06ec..60f09ac22 100644 --- a/packages/core/modules/use-cases/entity-types.js +++ b/packages/core/modules/use-cases/entity-types.js @@ -59,10 +59,31 @@ function findDefinition(moduleDefinitions, entityType) { return definition; } -const describeEntry = (name, entry) => ({ +/** + * With a scope policy the entry also says which scope a new connection gets + * and which a request may choose (ADR-056 §3.1), so a UI can offer the choice. + * `global` is never offered: a tenant request cannot create one. + */ +const scopeOf = (scopePolicy, name, user) => { + if (!scopePolicy) return {}; + const { scope, source, allow } = scopePolicy.resolveScope({ + entityType: name, + user, + }); + return { + scope: { + default: scope, + allow: allow.filter((value) => value !== 'global'), + source, + }, + }; +}; + +const describeEntry = (name, entry, scopePolicy, user) => ({ // The registry key is the name the API uses, whatever the definition spells. ...describeEntityType(entry.definition, name), usedBy: usedByOf(entry), + ...scopeOf(scopePolicy, name, user), }); function requireRegistry(entityTypeRegistry) { @@ -78,31 +99,34 @@ function requireRegistry(entityTypeRegistry) { * bind each type; it is empty for a registry-only type. */ class ListEntityTypes { - constructor({ entityTypeRegistry }) { + constructor({ entityTypeRegistry, scopePolicy }) { this.registry = requireRegistry(entityTypeRegistry); + this.scopePolicy = scopePolicy; } - async execute() { + /** @param {{user?: Object}} [params] - the caller, for the level-6 scope default */ + async execute({ user } = {}) { return [...this.registry] - .map(([name, entry]) => describeEntry(name, entry)) + .map(([name, entry]) => describeEntry(name, entry, this.scopePolicy, user)) .sort((a, b) => a.name.localeCompare(b.name)); } } /** Describes one entity type, looked up by its registry key; 404 when absent. */ class GetEntityType { - constructor({ entityTypeRegistry }) { + constructor({ entityTypeRegistry, scopePolicy }) { this.registry = requireRegistry(entityTypeRegistry); + this.scopePolicy = scopePolicy; } - async execute(entityType) { + async execute(entityType, { user } = {}) { const entry = this.registry.get(entityType); if (!entry) { throw Boom.notFound(`Entity type '${entityType}' not found`, { code: 'ENTITY_TYPE_NOT_FOUND', }); } - return describeEntry(entityType, entry); + return describeEntry(entityType, entry, this.scopePolicy, user); } } diff --git a/packages/core/modules/use-cases/execute-entity-proxy-request.js b/packages/core/modules/use-cases/execute-entity-proxy-request.js index 35fd85d17..09e943e18 100644 --- a/packages/core/modules/use-cases/execute-entity-proxy-request.js +++ b/packages/core/modules/use-cases/execute-entity-proxy-request.js @@ -98,9 +98,24 @@ class ExecuteEntityProxyRequest { audit.requestBytes = validated.requestBytes; const path = normalizeProxyPath(validated.path); + // Access is `use` in the active organization (ADR-056 §3.5, §3.6): the + // owner's entity there, or the organization's. A `global` entity is + // never the tenant's. const { entity, definition, module } = await this.getEntityModuleForUser.execute(entityId, user); audit.module = definition.moduleName; + // Narrowest by default (§3.5): proxying through a shared credential + // lets any member act on the organization's account, so only an + // individual entity may be proxied until `managementApi.proxy.scopes` + // lets an app opt in to `organization`. + // TODO(F6): read proxy.scopes here instead of refusing outright. + if (this.getEntityModuleForUser.describe(entity, user)?.scope !== 'individual') { + throw new ProxyRequestError( + 'PROXY_NOT_ALLOWED', + 'The entity proxy only accepts individual entities; organization-level entities are not proxied', + { module: definition.moduleName, reason: 'scope' } + ); + } const credentialRef = entity.credential; const credentialId = credentialRef && typeof credentialRef === 'object' diff --git a/packages/core/modules/use-cases/execute-entity-proxy-request.test.js b/packages/core/modules/use-cases/execute-entity-proxy-request.test.js index d64fa7c22..b714a2dc4 100644 --- a/packages/core/modules/use-cases/execute-entity-proxy-request.test.js +++ b/packages/core/modules/use-cases/execute-entity-proxy-request.test.js @@ -28,7 +28,7 @@ function upstream(status, data, headers = {}) { }; } -function setup({ entity, def = definition, proxyRequest, overrides } = {}) { +function setup({ entity, def = definition, proxyRequest, overrides, entityScope = 'individual' } = {}) { const api = { api_key_name: 'X-Crm-Key', _proxyRequest: jest.fn(proxyRequest || (async () => upstream(200, { id: '7', name: 'Ada' }))), @@ -42,6 +42,8 @@ function setup({ entity, def = definition, proxyRequest, overrides } = {}) { module: { api }, }; }), + // The scope the real GetEntityModuleForUser reads from the owner. + describe: jest.fn(() => ({ scope: entityScope })), }; const telemetry = { count: jest.fn() }; const config = resolveManagementApiConfig({ proxy: { enable: true, ...(overrides || {}) } }); @@ -139,6 +141,19 @@ describe('ExecuteEntityProxyRequest (ADR-052)', () => { expect(audits()[0]).toMatchObject({ level: 'WARN', outcome: 'denied', reason: 'PROXY_NOT_ALLOWED' }); }); + it.each(['organization', 'global', null])( + 'fails closed for an entity of scope %s until proxy.scopes lets an app opt in (ADR-056 §3.5)', + async (entityScope) => { + const { useCase, api } = setup({ entityScope }); + const error = await useCase.execute('e1', user, { method: 'GET', path: '/contacts/7' }).catch((e) => e); + expect(error.output.statusCode).toBe(403); + expect(error.data).toMatchObject({ code: 'PROXY_NOT_ALLOWED', details: { reason: 'scope' } }); + // Refused before the module is asked about the call, and nothing went upstream. + expect(api._proxyRequest).not.toHaveBeenCalled(); + expect(audits()[0]).toMatchObject({ outcome: 'denied', reason: 'PROXY_NOT_ALLOWED' }); + } + ); + it('denies a method or path the module does not list', async () => { const { useCase, api } = setup(); for (const request of [ diff --git a/packages/core/modules/use-cases/get-entities-for-user.js b/packages/core/modules/use-cases/get-entities-for-user.js index 57f782634..c667bb1da 100644 --- a/packages/core/modules/use-cases/get-entities-for-user.js +++ b/packages/core/modules/use-cases/get-entities-for-user.js @@ -1,8 +1,11 @@ const { Module } = require('../module'); const { mapModuleClassToModuleDTO } = require('../utils/map-module-dto'); +const { describeRecord, visibilityClauses } = require('../scope-visibility'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); class GetEntitiesForUser { - constructor({ moduleRepository, moduleDefinitions }) { + constructor({ moduleRepository, moduleDefinitions, scopePolicy }) { + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; this.moduleRepository = moduleRepository; this.definitionMap = new Map(); @@ -11,16 +14,24 @@ class GetEntitiesForUser { } } - async execute(userId) { - const entities = await this.moduleRepository.findEntitiesByUserId( - userId + /** + * The entities a `User` may see in the active organization (ADR-056 §3.6). + * @param {Object} user - authenticated User + */ + async execute(user) { + const ctx = this.scopePolicy.accessContext(user); + const found = await this.moduleRepository.findEntitiesByOwnership( + visibilityClauses(ctx, 'entity') + ); + const entities = (found || []).filter((entity) => + describeRecord(entity, ctx, 'entity') ); return entities.map((entity) => { const definition = this.definitionMap.get(entity.moduleName); const moduleInstance = new Module({ - userId, + userId: entity.userId, definition: definition, entity: entity, }); diff --git a/packages/core/modules/use-cases/get-entity-module-for-user.js b/packages/core/modules/use-cases/get-entity-module-for-user.js index ae8b892da..590c1ee4f 100644 --- a/packages/core/modules/use-cases/get-entity-module-for-user.js +++ b/packages/core/modules/use-cases/get-entity-module-for-user.js @@ -1,8 +1,13 @@ const Boom = require('@hapi/boom'); +const { describeRecord } = require('../scope-visibility'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); /** * True when `user` may act on records owned by `ownerId`. Uses the User * domain object's rule (individual or organisation user model) when present. + * This is the owner-only rule: it ignores the organization a record belongs + * to, so it no longer decides access to an entity or an integration + * (modules/scope-visibility.js does). Credentials still use it. * @param {Object} user - authenticated User (or a plain { getId } double) * @param {string|number} ownerId */ @@ -30,8 +35,11 @@ class GetEntityModuleForUser { * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository * @param {Array} params.moduleDefinitions * @param {Function} [params.createModule] - builds a Module; injectable for tests. + * @param {Object} [params.scopePolicy] - from createScopePolicy; with none the + * app configures nothing (ADR-056 §3.1). */ - constructor({ moduleRepository, moduleDefinitions, createModule }) { + constructor({ moduleRepository, moduleDefinitions, createModule, scopePolicy }) { + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; this.moduleRepository = moduleRepository; this.moduleDefinitions = moduleDefinitions || []; this.createModule = createModule || defaultCreateModule; @@ -61,7 +69,11 @@ class GetEntityModuleForUser { return { entity, definition, module }; } - /** The entity record, or a 404 when it is missing or not the caller's. */ + /** + * The entity record, or a 404 when it is missing or the caller may not use + * it: not theirs, or paired with another organization (ADR-056 §3.6). A + * `global` entity is never the tenant's to load. + */ async loadOwnedEntity(entityId, user) { let entity = null; try { @@ -74,13 +86,21 @@ class GetEntityModuleForUser { throw error; } } - if (!entity || !userOwns(user, entity.userId)) { + if (!entity || !this.describe(entity, user)) { throw Boom.notFound(`Entity ${entityId} not found`, { code: 'ENTITY_NOT_FOUND', }); } return entity; } + + /** + * `{ scope, owner, organizationId, access }` of an entity to this caller, + * or null when they may not see it (§3.4). + */ + describe(entity, user) { + return describeRecord(entity, this.scopePolicy.accessContext(user), 'entity'); + } } module.exports = { GetEntityModuleForUser, userOwns }; diff --git a/packages/core/modules/use-cases/get-entity-options-by-id.js b/packages/core/modules/use-cases/get-entity-options-by-id.js index dd5453973..c2c1fe631 100644 --- a/packages/core/modules/use-cases/get-entity-options-by-id.js +++ b/packages/core/modules/use-cases/get-entity-options-by-id.js @@ -1,4 +1,5 @@ const { Module } = require('../module'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); class GetEntityOptionsById { /** @@ -6,9 +7,11 @@ class GetEntityOptionsById { * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository * @param {} params.moduleDefinitions */ - constructor({ moduleRepository, moduleDefinitions }) { + constructor({ moduleRepository, moduleDefinitions, scopePolicy }) { this.moduleRepository = moduleRepository; this.moduleDefinitions = moduleDefinitions; + // Who may load an entity follows its scope (ADR-056 §3.6). + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; } /** @@ -35,7 +38,7 @@ class GetEntityOptionsById { // Validate entity ownership const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId - ? userIdOrUser.ownsUserId(entity.userId) + ? this.scopePolicy.canUseEntity(userIdOrUser, entity) : entity.userId?.toString() === userId?.toString(); if (!isOwned) { diff --git a/packages/core/modules/use-cases/get-module.js b/packages/core/modules/use-cases/get-module.js index 9a6c14917..f2dd1c1be 100644 --- a/packages/core/modules/use-cases/get-module.js +++ b/packages/core/modules/use-cases/get-module.js @@ -1,9 +1,12 @@ const { Module } = require('../module'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); class GetModule { - constructor({ moduleRepository, moduleDefinitions }) { + constructor({ moduleRepository, moduleDefinitions, scopePolicy }) { this.moduleRepository = moduleRepository; this.moduleDefinitions = moduleDefinitions; + // Who may load an entity follows its scope (ADR-056 §3.6). + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; } /** @@ -32,7 +35,7 @@ class GetModule { // If User object provided, use ownsUserId to check linked users // Otherwise fall back to simple equality check const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId - ? userIdOrUser.ownsUserId(entity.userId) + ? this.scopePolicy.canUseEntity(userIdOrUser, entity) : entity.userId?.toString() === userId?.toString(); if (!isOwned) { diff --git a/packages/core/modules/use-cases/multi-step-authorization.js b/packages/core/modules/use-cases/multi-step-authorization.js index fab653d1e..95d0135db 100644 --- a/packages/core/modules/use-cases/multi-step-authorization.js +++ b/packages/core/modules/use-cases/multi-step-authorization.js @@ -3,6 +3,8 @@ const { FetchError } = require('../../errors/fetch-error'); const { getLogger } = require('../../logs'); const { AuthorizationSession } = require('../domain/authorization-session'); const { findDefinition, getAuthStepCount } = require('./entity-types'); +const { describeRecord } = require('../scope-visibility'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); const log = getLogger('frigg.api.authorize'); @@ -144,21 +146,28 @@ class SubmitAuthorizationStep { moduleDefinitions, getAuthorizationRequirements, authorizationSessionRepository, - processAuthorizationCallback, + authorizeEntityForUser, moduleRepository, credentialRepository, createModule, + scopePolicy, }) { + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; this.moduleDefinitions = moduleDefinitions || []; this.getAuthorizationRequirements = getAuthorizationRequirements; this.sessions = authorizationSessionRepository; - this.processAuthorizationCallback = processAuthorizationCallback; + this.authorizeEntityForUser = authorizeEntityForUser; this.moduleRepository = moduleRepository; this.credentialRepository = credentialRepository; this.createModule = createModule || defaultCreateModule; } - async execute({ user, entityType, data, step = 1, sessionId, credentialId = null }) { + /** + * @param {Object} params + * @param {string} [params.scope] - the scope the caller asks for, from the + * deciding level's allow list (ADR-056 §3.1). Never `global`. + */ + async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope }) { if (!data || typeof data !== 'object' || Array.isArray(data)) { throw Boom.badRequest('data must be an object', { code: 'VALIDATION_ERROR', @@ -166,7 +175,15 @@ class SubmitAuthorizationStep { }); } const definition = findDefinition(this.moduleDefinitions, entityType); + // `userId` binds a session to the caller and is what GET /authorize + // computes too. The owner of the entity this creates is decided by + // scope (§3.2), not by user.primary, and is `ownership.ownerId`. const userId = user.getId(); + const ownership = this.authorizeEntityForUser.resolveOwnership({ + user, + entityType, + scope, + }); if (!isMultiStep(definition)) { if (step !== 1) { @@ -174,7 +191,7 @@ class SubmitAuthorizationStep { code: 'INVALID_STEP', }); } - return this.complete({ userId, entityType, params: data, credentialId }); + return this.complete({ ownership, user, entityType, params: data, credentialId }); } const totalSteps = getAuthStepCount(definition); @@ -205,7 +222,7 @@ class SubmitAuthorizationStep { ); } - const module = this.createModule({ userId, definition }); + const module = this.createModule({ userId: ownership.ownerId, definition }); let result; try { result = await definition.processAuthorizationStep( @@ -224,7 +241,8 @@ class SubmitAuthorizationStep { session.markComplete(); await this.sessions.update(session); const outcome = await this.complete({ - userId, + ownership, + user, entityType, params: result.authData || {}, credentialId, @@ -261,14 +279,15 @@ class SubmitAuthorizationStep { }; } - async complete({ userId, entityType, params, credentialId }) { + async complete({ ownership, user, entityType, params, credentialId }) { let callback; try { - callback = await this.processAuthorizationCallback.execute( - userId, + ({ result: callback } = await this.authorizeEntityForUser.execute({ + user, entityType, - params - ); + data: params, + ownership, + })); } catch (error) { throw toAuthorizationError(error); } @@ -276,13 +295,18 @@ class SubmitAuthorizationStep { this.moduleRepository.findEntityById(callback.entity_id), this.credentialRepository.findCredentialById(callback.credential_id), ]); + // What the new entity is to this caller (§3.4), so a client need not list again. + const tenancy = entity + ? describeRecord(entity, this.scopePolicy.accessContext(user), 'entity') + : null; const outcome = { status: 'complete', - entity, + entity: entity && tenancy ? { ...entity, tenancy } : entity, credential: credential && { ...credential, type: entityType, entityIds: entity ? [String(entity.id)] : [], + ...(tenancy && { tenancy }), }, }; if (credentialId && String(callback.credential_id) !== String(credentialId)) { diff --git a/packages/core/modules/use-cases/multi-step-authorization.test.js b/packages/core/modules/use-cases/multi-step-authorization.test.js index 0252ee934..f71ceae9a 100644 --- a/packages/core/modules/use-cases/multi-step-authorization.test.js +++ b/packages/core/modules/use-cases/multi-step-authorization.test.js @@ -1,6 +1,8 @@ const { FetchError } = require('../../errors/fetch-error'); const { ClientSafeError } = require('../../errors/client-safe-error'); const { GetAuthorizationRequirements } = require('./get-authorization-requirements'); +const { AuthorizeEntityForUser } = require('./authorize-entity-for-user'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); const { GetAuthorizationStep, SubmitAuthorizationStep } = require('./multi-step-authorization'); const { InMemoryAuthorizationSessionRepository, @@ -57,13 +59,17 @@ function setup() { moduleDefinitions: definitions, getAuthorizationRequirements, authorizationSessionRepository: sessions, - processAuthorizationCallback, + authorizeEntityForUser: new AuthorizeEntityForUser({ + scopePolicy: DEFAULT_SCOPE_POLICY, + processAuthorizationCallback, + }), moduleRepository, credentialRepository, createModule, }; return { ...deps, + processAuthorizationCallback, sessions, otp: definitions[0], getStep: new GetAuthorizationStep(deps), @@ -80,7 +86,10 @@ describe('multi-step authorization', () => { const result = await t.submit.execute({ user: user(), entityType: 'simple', data: { code: 'abc' } }); expect(result.status).toBe('complete'); - expect(t.processAuthorizationCallback.execute).toHaveBeenCalledWith('u1', 'simple', { code: 'abc' }); + expect(t.processAuthorizationCallback.execute).toHaveBeenCalledWith( + 'u1', 'simple', { code: 'abc' }, + { scope: 'individual', organizationId: null, legacyOrgId: null } + ); expect(t.sessions.rows.size).toBe(0); }); @@ -108,9 +117,10 @@ describe('multi-step authorization', () => { expect(done.status).toBe('complete'); expect(done.entity).toMatchObject({ id: 'e1' }); expect(done.credential).toMatchObject({ id: 'c1', type: 'otp', entityIds: ['e1'] }); - expect(t.processAuthorizationCallback.execute).toHaveBeenCalledWith('u1', 'otp', { - email: 'a@example.com', token: 'issued', - }); + expect(t.processAuthorizationCallback.execute).toHaveBeenCalledWith( + 'u1', 'otp', { email: 'a@example.com', token: 'issued' }, + { scope: 'individual', organizationId: null, legacyOrgId: null } + ); expect(t.otp.processAuthorizationStep).toHaveBeenLastCalledWith( expect.objectContaining({ kind: 'api' }), 2, { otp: '123456' }, { email: 'a@example.com' } ); diff --git a/packages/core/modules/use-cases/process-authorization-callback.js b/packages/core/modules/use-cases/process-authorization-callback.js index 2e6c418b4..24e673d2e 100644 --- a/packages/core/modules/use-cases/process-authorization-callback.js +++ b/packages/core/modules/use-cases/process-authorization-callback.js @@ -1,5 +1,16 @@ const { Module } = require('../module'); const { ModuleConstants } = require('../ModuleConstants'); +const { pickExistingEntity } = require('../scope-resolution'); + +/** + * What an entity belongs to when the caller says nothing: the person's own, + * outside any organization. + */ +const DEFAULT_TENANCY = Object.freeze({ + scope: 'individual', + organizationId: null, + legacyOrgId: null, +}); const { getLogger } = require('../../logs'); const log = getLogger('frigg.modules.authorization_callback'); @@ -30,7 +41,17 @@ class ProcessAuthorizationCallback { this.integrationRepository = integrationRepository; } - async execute(userId, entityType, params) { + /** + * @param {string} userId - the OWNER of what this creates (ADR-056 §3.2): the + * person for `individual`, the organization's user for `organization`. + * Not necessarily the caller; callers resolve it from the scope. + * @param {string} entityType + * @param {Object} params - what the module's authorization needs + * @param {{scope: string, organizationId: (string|null), legacyOrgId: (string|null)}} [tenancy] + * the entity's scope and organization (§3.6); default: the person's own, + * with no organization + */ + async execute(userId, entityType, params, tenancy = DEFAULT_TENANCY) { log.debug('Authorization callback started', { eventName: `${log.name}.started`, userId, @@ -57,10 +78,7 @@ class ProcessAuthorizationCallback { userId, entityType ); - const existingEntity = - existingEntities && existingEntities.length > 0 - ? existingEntities[0] - : null; + const existingEntity = pickExistingEntity(existingEntities, tenancy); if (existingEntity) { log.debug('Existing entity found', { @@ -156,7 +174,8 @@ class ProcessAuthorizationCallback { const persistedEntity = await this.findOrCreateEntity( entityDetails, entityType, - module.credential.id + module.credential.id, + tenancy ); log.debug('Authorization callback completed', { eventName: `${log.name}.completed`, @@ -233,7 +252,12 @@ class ProcessAuthorizationCallback { module.credential = persisted; } - async findOrCreateEntity(entityDetails, moduleName, credentialId) { + async findOrCreateEntity( + entityDetails, + moduleName, + credentialId, + tenancy = DEFAULT_TENANCY + ) { const { identifiers, details } = entityDetails; // Support both 'user' and 'userId' field names from module definitions @@ -247,11 +271,16 @@ class ProcessAuthorizationCallback { ); } - const existingEntity = await this.moduleRepository.findEntity({ - externalId: identifiers.externalId, - user: userId, - moduleName: moduleName, - }); + // The same provider account in two organizations is two entities + // (ADR-056 §3.6), so the match is per organization. + const existingEntity = pickExistingEntity( + await this.moduleRepository.findEntities({ + externalId: identifiers.externalId, + user: userId, + moduleName: moduleName, + }), + tenancy + ); if (existingEntity) { // Repoint the entity's credentialId when re-auth produced a @@ -282,12 +311,17 @@ class ProcessAuthorizationCallback { return existingEntity; } - return await this.moduleRepository.createEntity({ - ...identifiers, - ...details, - moduleName: moduleName, - credential: credentialId, - }); + // `organizationId` is not part of the entity data: that carries provider + // fields, and one that happens to be called organizationId is theirs. + return await this.moduleRepository.createEntity( + { + ...identifiers, + ...details, + moduleName: moduleName, + credential: credentialId, + }, + { organizationId: tenancy.organizationId ?? null } + ); } } diff --git a/packages/core/modules/use-cases/refresh-entity-options.js b/packages/core/modules/use-cases/refresh-entity-options.js index 881d27ffd..7a6064bf6 100644 --- a/packages/core/modules/use-cases/refresh-entity-options.js +++ b/packages/core/modules/use-cases/refresh-entity-options.js @@ -1,4 +1,5 @@ const { Module } = require('../module'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); class RefreshEntityOptions { /** @@ -6,9 +7,11 @@ class RefreshEntityOptions { * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository * @param {} params.moduleDefinitions */ - constructor({ moduleRepository, moduleDefinitions }) { + constructor({ moduleRepository, moduleDefinitions, scopePolicy }) { this.moduleRepository = moduleRepository; this.moduleDefinitions = moduleDefinitions; + // Who may load an entity follows its scope (ADR-056 §3.6). + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; } /** @@ -36,7 +39,7 @@ class RefreshEntityOptions { // Validate entity ownership const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId - ? userIdOrUser.ownsUserId(entity.userId) + ? this.scopePolicy.canUseEntity(userIdOrUser, entity) : entity.userId?.toString() === userId?.toString(); if (!isOwned) { diff --git a/packages/core/modules/use-cases/test-module-auth.js b/packages/core/modules/use-cases/test-module-auth.js index e11e3ce1e..c348fa15e 100644 --- a/packages/core/modules/use-cases/test-module-auth.js +++ b/packages/core/modules/use-cases/test-module-auth.js @@ -1,4 +1,5 @@ const { Module } = require('../module'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); class TestModuleAuth { /** @@ -6,9 +7,11 @@ class TestModuleAuth { * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository - Repository for module data operations. * @param {Array} params.moduleDefinitions - Array of module definitions. */ - constructor({ moduleRepository, moduleDefinitions }) { + constructor({ moduleRepository, moduleDefinitions, scopePolicy }) { this.moduleRepository = moduleRepository; this.moduleDefinitions = moduleDefinitions; + // Who may load an entity follows its scope (ADR-056 §3.6). + this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY; } /** @@ -35,7 +38,7 @@ class TestModuleAuth { // Validate entity ownership const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId - ? userIdOrUser.ownsUserId(entity.userId) + ? this.scopePolicy.canUseEntity(userIdOrUser, entity) : entity.userId?.toString() === userId?.toString(); if (!isOwned) { diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 97e37be2b..7dba8604d 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -134,6 +134,14 @@ model Entity { moduleName String? externalId String? + // Organization this entity was connected in (ADR-056 §3.6): the active + // organization for `individual`, the owner for `organization`, null for + // `global`. Null on an `individual` entity is also what every row written + // before this column existed has, and what `individualAcross: 'all'` writes. + // Deliberately not a relation: a deleted organization must leave its rows + // invisible, not turn them into "unscoped" ones (SetNull would do that). + organizationId String? @db.ObjectId + data Json @default("{}") createdAt DateTime @default(now()) @@ -150,6 +158,7 @@ model Entity { associationObjects AssociationObject[] @@index([userId]) + @@index([userId, organizationId]) @@index([externalId]) @@index([moduleName]) @@index([credentialId]) @@ -167,6 +176,11 @@ model Integration { user User? @relation(fields: [userId], references: [id], onDelete: Cascade) status IntegrationStatus @default(IN_CREATION) + // Organization this integration was created in (ADR-056 §3.6): the active + // organization at create time. Null for rows that predate the column. Not a + // relation, for the same reason as Entity.organizationId. + organizationId String? @db.ObjectId + // Configuration and version config Json? // Integration configuration object version String? @@ -191,6 +205,7 @@ model Integration { processes Process[] @@index([userId]) + @@index([userId, organizationId]) @@index([status]) @@map("Integration") } diff --git a/packages/core/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql b/packages/core/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql new file mode 100644 index 000000000..f3a9e3dd4 --- /dev/null +++ b/packages/core/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql @@ -0,0 +1,25 @@ +-- Entity and Integration carry the organization they were created in (ADR-056 §3.6). +-- Nullable, no default and no backfill: a row written before this migration keeps +-- NULL, and the domain (modules/entity-scope.js) reads NULL as "predates scoping" +-- (docs/guides/entity-scopes.md). No scope column: the owner's user type says it. +-- Deliberately no foreign key: a deleted organization must leave its rows +-- invisible, and ON DELETE SET NULL would turn them into unscoped ones. +-- +-- Release note: the two CREATE INDEX statements below are plain (not +-- CONCURRENTLY, which Prisma migrations cannot run inside a transaction). Each +-- takes a SHARE lock on its table while it builds, so writes to "Entity" and +-- "Integration" wait for it. Both tables are small in a typical Frigg +-- deployment; on a very large one, create the indexes by hand first with +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS under the same names, then deploy. + +-- AlterTable +ALTER TABLE "Entity" ADD COLUMN "organizationId" INTEGER; + +-- AlterTable +ALTER TABLE "Integration" ADD COLUMN "organizationId" INTEGER; + +-- CreateIndex +CREATE INDEX "Entity_userId_organizationId_idx" ON "Entity"("userId", "organizationId"); + +-- CreateIndex +CREATE INDEX "Integration_userId_organizationId_idx" ON "Integration"("userId", "organizationId"); diff --git a/packages/core/prisma-postgresql/schema.prisma b/packages/core/prisma-postgresql/schema.prisma index 349e1b811..40a75d597 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -131,6 +131,14 @@ model Entity { moduleName String? externalId String? + // Organization this entity was connected in (ADR-056 §3.6): the active + // organization for `individual`, the owner for `organization`, null for + // `global`. Null on an `individual` entity is also what every row written + // before this column existed has, and what `individualAcross: 'all'` writes. + // Deliberately not a relation: a deleted organization must leave its rows + // invisible, not turn them into "unscoped" ones (SetNull would do that). + organizationId Int? + data Json @default("{}") createdAt DateTime @default(now()) @@ -144,6 +152,7 @@ model Entity { associationObjects AssociationObject[] @@index([userId]) + @@index([userId, organizationId]) @@index([externalId]) @@index([moduleName]) @@index([credentialId]) @@ -160,6 +169,11 @@ model Integration { user User? @relation(fields: [userId], references: [id], onDelete: Cascade) status IntegrationStatus @default(IN_CREATION) + // Organization this integration was created in (ADR-056 §3.6): the active + // organization at create time. Null for rows that predate the column. Not a + // relation, for the same reason as Entity.organizationId. + organizationId Int? + // Configuration and version config Json? // Integration configuration object version String? @@ -183,6 +197,7 @@ model Integration { processes Process[] @@index([userId]) + @@index([userId, organizationId]) @@index([status]) } diff --git a/packages/core/types/modules/index.d.ts b/packages/core/types/modules/index.d.ts index c675b801b..8ead7037e 100644 --- a/packages/core/types/modules/index.d.ts +++ b/packages/core/types/modules/index.d.ts @@ -36,13 +36,100 @@ declare module "@friggframework/core" { [entityType: string]: EntityRegistration | boolean | undefined; } + /** + * One level's setting: a scope, or a default plus the scopes a request may + * choose from (ADR-056 §3.1). `global` is never chosen by a tenant request. + */ + export type ScopeSetting = + | EntityScope + | { default?: EntityScope; allow?: EntityScope[] }; + + /** + * `appDefinition.ownership` (ADR-056 §3, §3.6). Resolution order for a new + * connection: request, binding, integration, entity type (`entities[name].scope`), + * this `scope`, then `organization` for an organization-primary app and + * `individual` otherwise. + */ + export interface AppOwnership { + scope?: ScopeSetting; + /** + * `'organization'` (default): an individual entity belongs to the organization + * it was connected in. `'all'`: one connection per person across every + * organization, stored with no `organizationId`. + */ + individualAcross?: "organization" | "all"; + } + + /** `Definition.ownership` on an integration (ADR-056 §3.1, §3.3). */ + export interface IntegrationOwnership { + /** Scope every binding of this integration accepts unless it sets its own. */ + scope?: ScopeSetting; + /** `'derive'` (default): organization when every entity it uses is organization-scoped. */ + owner?: "derive" | "individual" | "organization"; + } + export interface AppDefinition { integrations: any[]; /** App-level entity type registry (ADR-059). */ entities?: AppDefinitionEntities; + /** Who owns what a person connects (ADR-056). */ + ownership?: AppOwnership; [key: string]: unknown; } + /** Whose a record is and what the caller may do with it (ADR-056 §3.4). */ + export interface RecordTenancy { + scope: Exclude; + owner: { type: Exclude; id: string }; + /** Null for an individual entity connected outside an organization, or under `individualAcross: 'all'`. */ + organizationId: string | null; + /** `manage`: create, reauthorize, delete. `use`: bind it, proxy through it. `view`: metadata only. */ + access: "manage" | "use" | "view"; + } + + /** Which scope a connection gets, and from which level of the order (ADR-056 §3.1). */ + export interface ResolvedScope { + scope: EntityScope; + /** Where `scope` came from. `request` when the request chose within the allowed list. */ + source: "request" | "binding" | "integration" | "entityType" | "app" | "default"; + /** The level that decided what a request may choose from. */ + decidedBy: "binding" | "integration" | "entityType" | "app" | "default"; + allow: EntityScope[]; + } + + /** + * First match wins, each level overriding the ones below it. A request may + * choose only from the deciding level's `allow`; `global` never comes from a + * tenant request. Throws a Boom 400/403 for a request scope that is not allowed. + */ + export function resolveScope(levels: { + request?: EntityScope; + binding?: ScopeSetting; + integration?: ScopeSetting; + entityType?: ScopeSetting; + app?: ScopeSetting; + primary?: "individual" | "organization"; + }): ResolvedScope; + + /** Every binding of every integration with its resolved scope (for tooling that wants to show the chain). */ + export function listBindingScopes( + appDefinition: Pick, + entityTypeRegistry: EntityTypeRegistry, + primary?: "individual" | "organization" + ): Array<{ + integration: string; + binding: string; + moduleName: string | null; + scope: EntityScope; + source: ResolvedScope["source"]; + owner: "derive" | "individual" | "organization"; + }>; + + /** Invalid ownership or scope setting, thrown at app-definition load. `code` is always `OWNERSHIP_CONFIG_INVALID`. */ + export class OwnershipConfigError extends Error { + code: "OWNERSHIP_CONFIG_INVALID"; + } + /** * Builds the registry from `entities` plus the modules integrations bind. * Throws EntityRegistryError on an invalid registry. diff --git a/packages/schemas/schemas/api-authorization.schema.json b/packages/schemas/schemas/api-authorization.schema.json index 15d4e2ebd..4c0b18ac7 100644 --- a/packages/schemas/schemas/api-authorization.schema.json +++ b/packages/schemas/schemas/api-authorization.schema.json @@ -173,6 +173,11 @@ "default": 1, "description": "Current step number for multi-step flows" }, + "scope": { + "type": "string", + "enum": ["individual", "organization"], + "description": "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." + }, "sessionId": { "type": "string", "description": "Session ID from previous step (required for step > 1)" diff --git a/packages/schemas/schemas/api-entities.schema.json b/packages/schemas/schemas/api-entities.schema.json index 0caae9ac6..929f78bcd 100644 --- a/packages/schemas/schemas/api-entities.schema.json +++ b/packages/schemas/schemas/api-entities.schema.json @@ -37,6 +37,28 @@ "type": "boolean", "description": "Whether authentication is currently valid" }, + "scope": { + "type": "string", + "enum": ["individual", "organization"], + "description": "Whose the entity is (ADR-056 §3.4). `global` ones are not listed to tenants." + }, + "owner": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["individual", "organization"] }, + "id": { "type": "string" } + }, + "description": "The user that owns it: the person, or the organization's user" + }, + "organizationId": { + "type": ["string", "null"], + "description": "The organization it belongs to. Null for an individual entity connected outside an organization, or under ownership.individualAcross 'all'." + }, + "access": { + "type": "string", + "enum": ["manage", "use", "view"], + "description": "What the caller may do: manage (create, reauthorize, delete), use (bind it, proxy through it) or view (metadata only)" + }, "createdAt": { "type": "string", "format": "date-time", @@ -147,6 +169,15 @@ "type": "string" }, "description": "List of capabilities this entity type supports" + }, + "scope": { + "type": "object", + "description": "The scope a new connection of this type gets and the scopes a request may choose (ADR-056 §3.1). global is never offered.", + "properties": { + "default": { "type": "string", "enum": ["individual", "organization", "global"] }, + "allow": { "type": "array", "items": { "type": "string", "enum": ["individual", "organization"] } }, + "source": { "type": "string", "enum": ["binding", "integration", "entityType", "app", "default"] } + } } } }, diff --git a/packages/schemas/schemas/app-definition.schema.json b/packages/schemas/schemas/app-definition.schema.json index 192a69dbb..caf6928be 100644 --- a/packages/schemas/schemas/app-definition.schema.json +++ b/packages/schemas/schemas/app-definition.schema.json @@ -105,6 +105,16 @@ "description": "Require organization user for authentication", "default": false }, + "strictMembership": { + "type": "boolean", + "description": "Reject x-frigg headers that name an organization the person is not already a member of, instead of adding the membership (ADR-056 §3.6). Turn on once entities and integrations are scoped by organization and your app manages membership itself.", + "default": false + }, + "strictUserValidation": { + "type": "boolean", + "description": "DEPRECATED alias of strictMembership; its meaning changed.", + "default": false + }, "authModes": { "type": "object", "description": "Authentication mode configuration - enables multiple authentication strategies. x-frigg-appUserId and x-frigg-appOrgId headers are automatically supported for user identification with any auth mode.", @@ -735,6 +745,38 @@ "additionalProperties": false } }, + "ownership": { + "type": "object", + "description": "Who owns what a person connects (ADR-056 §3). `scope` is the app-level default for any entity type or integration binding that sets none; the order is request, binding, integration, entity type, app, then `organization` for an organization-primary app and `individual` otherwise.", + "properties": { + "scope": { + "description": "One scope, or a default plus the scopes a request may choose from.", + "oneOf": [ + { "type": "string", "enum": ["individual", "organization", "global"] }, + { + "type": "object", + "properties": { + "default": { "type": "string", "enum": ["individual", "organization", "global"] }, + "allow": { + "type": "array", + "items": { "type": "string", "enum": ["individual", "organization", "global"] }, + "minItems": 1, + "uniqueItems": true + } + }, + "additionalProperties": false + } + ] + }, + "individualAcross": { + "type": "string", + "enum": ["organization", "all"], + "default": "organization", + "description": "`organization` (default): an individual entity belongs to the organization it was connected in and is not visible in another. `all`: one connection per person across every organization they belong to, stored with no organizationId." + } + }, + "additionalProperties": false + }, "managementApi": { "type": "object", "description": "Management API settings (ADR-053 versioning, ADR-052 entity proxy).", diff --git a/packages/schemas/schemas/integration-definition.schema.json b/packages/schemas/schemas/integration-definition.schema.json index b34baa597..70b43424e 100644 --- a/packages/schemas/schemas/integration-definition.schema.json +++ b/packages/schemas/schemas/integration-definition.schema.json @@ -263,11 +263,62 @@ "definition": { "type": "object", "description": "The API module definition (e.g., from @friggframework/api-module-*)" + }, + "scope": { + "description": "The scope this binding accepts (ADR-056 §3.1): an entity bound here must have it. Overrides ownership.scope.", + "oneOf": [ + { "type": "string", "enum": ["individual", "organization", "global"] }, + { + "type": "object", + "properties": { + "default": { "type": "string", "enum": ["individual", "organization", "global"] }, + "allow": { + "type": "array", + "items": { "type": "string", "enum": ["individual", "organization", "global"] }, + "minItems": 1, + "uniqueItems": true + } + }, + "additionalProperties": false + } + ] } }, "additionalProperties": true } }, + "ownership": { + "type": "object", + "description": "Who owns this integration and what scope its bindings accept (ADR-056 §3.1, §3.3).", + "properties": { + "scope": { + "description": "One scope, or a default plus the scopes a request may choose from.", + "oneOf": [ + { "type": "string", "enum": ["individual", "organization", "global"] }, + { + "type": "object", + "properties": { + "default": { "type": "string", "enum": ["individual", "organization", "global"] }, + "allow": { + "type": "array", + "items": { "type": "string", "enum": ["individual", "organization", "global"] }, + "minItems": 1, + "uniqueItems": true + } + }, + "additionalProperties": false + } + ] + }, + "owner": { + "type": "string", + "enum": ["derive", "individual", "organization"], + "default": "derive", + "description": "`derive` (default): the organization when every entity it uses is organization-scoped, otherwise the person. An organization-owned integration cannot bind an individual entity." + } + }, + "additionalProperties": false + }, "hasUserConfig": { "type": "boolean", "description": "Whether integration requires user-specific configuration", diff --git a/packages/schemas/tests/app-definition-ownership.test.js b/packages/schemas/tests/app-definition-ownership.test.js new file mode 100644 index 000000000..53040130c --- /dev/null +++ b/packages/schemas/tests/app-definition-ownership.test.js @@ -0,0 +1,81 @@ +const { validateAppDefinition, validateIntegrationDefinition } = require("../index"); + +const baseDefinition = { + name: "test-app", + provider: "aws", + integrations: [], +}; + +const validate = (extra) => validateAppDefinition({ ...baseDefinition, ...extra }); + +describe("app-definition schema: ownership (ADR-056 §3)", () => { + it("stays optional", () => { + expect(validate({}).valid).toBe(true); + }); + + it("accepts a scope, a default with an allow list, and individualAcross", () => { + expect(validate({ ownership: { scope: "organization" } }).valid).toBe(true); + expect( + validate({ + ownership: { + scope: { default: "organization", allow: ["organization", "individual"] }, + individualAcross: "all", + }, + }).valid + ).toBe(true); + expect(validate({ ownership: { individualAcross: "organization" } }).valid).toBe(true); + }); + + it.each([ + [{ scope: "team" }], + [{ scope: { default: "team" } }], + [{ scope: { default: "individual", allow: [] } }], + [{ scope: { default: "individual", allow: ["individual", "individual"] } }], + [{ scope: { default: "individual", extra: 1 } }], + [{ individualAcross: "everywhere" }], + [{ owner: "organization" }], + ])("rejects %j", (ownership) => { + expect(validate({ ownership }).valid).toBe(false); + }); + + it("accepts the membership flags the user block carries (ADR-056 §3.6)", () => { + expect(validate({ user: { strictMembership: true } }).valid).toBe(true); + expect(validate({ user: { strictUserValidation: true } }).valid).toBe(true); + expect(validate({ user: { strictMembership: "yes" } }).valid).toBe(false); + }); +}); + +describe("integration-definition schema: ownership and binding scope", () => { + const definition = (extra) => ({ + name: "sync", + version: "1.0.0", + modules: { crm: { definition: { moduleName: "hubspot" } } }, + ...extra, + }); + + it("accepts ownership.scope and ownership.owner", () => { + expect( + validateIntegrationDefinition( + definition({ ownership: { scope: "organization", owner: "derive" } }) + ).valid + ).toBe(true); + expect(validateIntegrationDefinition(definition({ ownership: { owner: "organization" } })).valid).toBe(true); + }); + + it.each([[{ owner: "team" }], [{ scope: "team" }], [{ other: 1 }]])( + "rejects ownership %j", + (ownership) => { + expect(validateIntegrationDefinition(definition({ ownership })).valid).toBe(false); + } + ); + + it("accepts a scope on a module binding and rejects a bad one", () => { + const withScope = (scope) => + definition({ modules: { crm: { definition: { moduleName: "hubspot" }, scope } } }); + expect(validateIntegrationDefinition(withScope("individual")).valid).toBe(true); + expect( + validateIntegrationDefinition(withScope({ default: "individual", allow: ["individual", "organization"] })).valid + ).toBe(true); + expect(validateIntegrationDefinition(withScope("team")).valid).toBe(false); + }); +}); From 3c8b68a5062138b3b869c284daceb427f055aed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:36:50 +0000 Subject: [PATCH 3/7] fix(core): pin entity identity after provider data; v1 GET /api/authorize uses the scope owner - _mapEntity (postgres, mongo, documentdb) spread the provider's `data` over `id` and `userId` and only pinned `organizationId` afterwards, so a stored data.id or data.userId replaced the row's identity, which drives scope, access and every later load. `data` is now spread first and every column is pinned after it. The pre-F5 mappers had the same order (data last), so this was an existing weakness that F5's ownership checks made dangerous. The integration repositories map `config` explicitly and do not spread. - v1 GET /api/authorize built the module with user.getId() while POST stores under the resolved scope owner. It now asks AuthorizeEntityForUser for the owner, as POST does. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- .../core/integrations/integration-router.js | 8 +++- .../integration-router-ownership.test.js | 18 ++++++++ .../module-repository-ownership.test.js | 43 +++++++++++++++++++ .../module-repository-documentdb.js | 5 ++- .../repositories/module-repository-mongo.js | 7 +-- .../module-repository-postgres.js | 7 +-- 6 files changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/core/integrations/integration-router.js b/packages/core/integrations/integration-router.js index 776b07ad8..2743b115c 100644 --- a/packages/core/integrations/integration-router.js +++ b/packages/core/integrations/integration-router.js @@ -532,10 +532,14 @@ function setEntityRoutes(router, authenticateUser, useCases) { router.route('/api/authorize').get( catchAsyncError(async (req, res) => { const user = await authenticateUser.execute(req); - const userId = user.getId(); const params = checkRequiredParams(req.query, ['entityType']); + // The owner POST stores under, so GET and POST describe one connection. + const { ownerId } = authorizeEntityForUser.resolveOwnership({ + user, + entityType: params.entityType, + }); const module = await getModuleInstanceFromType.execute( - userId, + ownerId, params.entityType, { state: req.query.state } ); diff --git a/packages/core/integrations/tests/integration-router-ownership.test.js b/packages/core/integrations/tests/integration-router-ownership.test.js index 7b510dfab..e2de31d51 100644 --- a/packages/core/integrations/tests/integration-router-ownership.test.js +++ b/packages/core/integrations/tests/integration-router-ownership.test.js @@ -53,6 +53,7 @@ const { createApp } = require('../../handlers/app-handler-helpers'); const { AuthenticateUser } = require('../../user/use-cases/authenticate-user'); const { User } = require('../../user/user'); const { ProcessAuthorizationCallback } = require('../../modules/use-cases/process-authorization-callback'); +const { GetModuleInstanceFromType } = require('../../modules/use-cases/get-module-instance-from-type'); const { CreateIntegration } = require('../use-cases/create-integration'); const { GetIntegrationsForUser } = require('../use-cases/get-integrations-for-user'); const { GetIntegrationInstance } = require('../use-cases/get-integration-instance'); @@ -114,6 +115,23 @@ describe('v1 routes follow the ownership rules', () => { }); }); + it('GET /api/authorize builds the module for the owner POST stores under, not for user.getId()', async () => { + const instance = jest + .spyOn(GetModuleInstanceFromType.prototype, 'execute') + .mockResolvedValue({ + validateAuthorizationRequirements: () => true, + getAuthorizationRequirements: () => ({ url: 'https://example.test' }), + }); + const post = jest + .spyOn(ProcessAuthorizationCallback.prototype, 'execute') + .mockResolvedValue({ credential_id: 'c', entity_id: 'e' }); + expect((await call('GET', '/api/authorize?entityType=acme')).status).toBe(200); + expect((await call('POST', '/api/authorize', { entityType: 'acme', data: { key: 'k' } })).status).toBe(200); + // acme is organization-scoped: organization 17 owns it, not person 7. + expect(instance.mock.calls[0][0]).toBe('17'); + expect(post.mock.calls[0][0]).toBe('17'); + }); + it('POST /api/authorize: 400 when the scope needs an organization the request does not have', async () => { const execute = jest.spyOn(ProcessAuthorizationCallback.prototype, 'execute'); currentUser = caller(); diff --git a/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js b/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js index 65ac16325..9c933b60e 100644 --- a/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js +++ b/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js @@ -50,6 +50,15 @@ describe('ModuleRepositoryPostgres', () => { expect(found[0]).toMatchObject({ id: '5', userId: '7', organizationId: '17' }); }); + it('provider data cannot replace the row identity: id, userId, organizationId and the other columns come from the row', async () => { + const repo = make([ + { id: 5, userId: 7, organizationId: 17, name: 'n', externalId: 'x', moduleName: 'm', data: { id: 'evil-id', userId: 'evil-user', organizationId: 'evil-org', moduleName: 'evil', externalId: 'evil-ext', name: 'evil-name', credential: 'evil-cred' } }, + ]); + const [entity] = await repo.findEntitiesByOwnership(clauses); + expect(entity).toMatchObject({ id: '5', userId: '7', organizationId: '17', name: 'n', externalId: 'x', moduleName: 'm' }); + expect(entity.credential).not.toBe('evil-cred'); + }); + it('asks for nothing when there are no clauses', async () => { const repo = make(); expect(await repo.findEntitiesByOwnership([])).toEqual([]); @@ -108,6 +117,15 @@ describe('ModuleRepositoryMongo', () => { OR: [{ organizationId: null }, { organizationId: { isSet: false } }], }); + it('provider data cannot replace the row identity: id, userId, organizationId and the other columns come from the row', async () => { + const repo = make([ + { id: '5', userId: '7', organizationId: '17', name: 'n', externalId: 'x', moduleName: 'm', data: { id: 'evil-id', userId: 'evil-user', organizationId: 'evil-org', moduleName: 'evil', externalId: 'evil-ext', name: 'evil-name', credential: 'evil-cred' } }, + ]); + const [entity] = await repo.findEntitiesByOwnership(clauses); + expect(entity).toMatchObject({ id: '5', userId: '7', organizationId: '17', name: 'n', externalId: 'x', moduleName: 'm' }); + expect(entity.credential).not.toBe('evil-cred'); + }); + it('matches a null organization as null OR unset: rows written before the column have no field', async () => { const repo = make(); await repo.findEntitiesByOwnership(clauses); @@ -181,4 +199,29 @@ describe('ModuleRepositoryDocumentDB', () => { expect(doc.organizationId.toString()).toBe(ids.u17.toString()); expect(doc.data).toEqual({ organizationId: 'provider' }); }); + it('provider data cannot replace the document identity: id, userId, organizationId and the other columns come from the document', async () => { + const { repo, $runCommandRaw } = make(); + $runCommandRaw.mockImplementation(async (command) => + command.find === 'Entity' + ? { + cursor: { + firstBatch: [ + { + _id: { $oid: ids.u7.toString() }, + userId: { $oid: ids.u7.toString() }, + organizationId: { $oid: ids.u17.toString() }, + name: 'n', externalId: 'x', moduleName: 'm', + data: { id: 'evil-id', userId: 'evil-user', organizationId: 'evil-org', moduleName: 'evil', externalId: 'evil-ext', name: 'evil-name', credential: 'evil-cred' }, + }, + ], + }, + } + : { cursor: { firstBatch: [] } } + ); + const [entity] = await repo.findEntitiesByOwnership([{ userId: ids.u7.toString(), organizationId: ids.u17.toString() }]); + expect(entity).toMatchObject({ + id: ids.u7.toString(), userId: ids.u7.toString(), organizationId: ids.u17.toString(), + name: 'n', externalId: 'x', moduleName: 'm', + }); + }); }); diff --git a/packages/core/modules/repositories/module-repository-documentdb.js b/packages/core/modules/repositories/module-repository-documentdb.js index d3d5510dd..714fb3109 100644 --- a/packages/core/modules/repositories/module-repository-documentdb.js +++ b/packages/core/modules/repositories/module-repository-documentdb.js @@ -373,14 +373,15 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface { _mapEntity(doc, credential) { const dynamicData = doc?.data || {}; return { + // First, so no stored provider field can replace a column below + // (identity, owner or organization decide who sees the entity). + ...dynamicData, id: fromObjectId(doc?._id), credential, userId: fromObjectId(doc?.userId), name: doc?.name ?? null, externalId: doc?.externalId ?? null, moduleName: doc?.moduleName ?? null, - ...dynamicData, - // After the spread: a provider field of the same name must not decide visibility. organizationId: fromObjectId(doc?.organizationId) ?? null, }; } diff --git a/packages/core/modules/repositories/module-repository-mongo.js b/packages/core/modules/repositories/module-repository-mongo.js index a4158f8e7..ba79e15dd 100644 --- a/packages/core/modules/repositories/module-repository-mongo.js +++ b/packages/core/modules/repositories/module-repository-mongo.js @@ -79,19 +79,20 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { /** * The one place a stored entity row becomes the entity the application sees. - * `organizationId` comes after the entity data: a provider field of the same - * name must not decide who sees the entity. + * The provider's `data` is spread FIRST and every column is pinned after it: + * a stored `id`, `userId` or `organizationId` in `data` must not decide who + * owns the entity or who sees it. * @private */ _mapEntity(row, credential) { return { + ...(row.data || {}), id: row.id, credential, userId: row.userId, name: row.name, externalId: row.externalId, moduleName: row.moduleName, - ...(row.data || {}), organizationId: row.organizationId ?? null, }; } diff --git a/packages/core/modules/repositories/module-repository-postgres.js b/packages/core/modules/repositories/module-repository-postgres.js index 4eb78d08f..bc6e09fc2 100644 --- a/packages/core/modules/repositories/module-repository-postgres.js +++ b/packages/core/modules/repositories/module-repository-postgres.js @@ -110,19 +110,20 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { /** * The one place a stored entity row becomes the entity the application sees. - * `organizationId` comes after the entity data: a provider field of the same - * name must not decide who sees the entity. + * The provider's `data` is spread FIRST and every column is pinned after it: + * a stored `id`, `userId` or `organizationId` in `data` must not decide who + * owns the entity or who sees it. * @private */ _mapEntity(row, credential) { return { + ...(row.data || {}), id: row.id.toString(), credential, userId: row.userId?.toString(), name: row.name, externalId: row.externalId, moduleName: row.moduleName, - ...(row.data || {}), organizationId: row.organizationId?.toString() ?? null, }; } From a2bcc2f124b0574c0d868beff77b4da848fd7849 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:13:31 +0000 Subject: [PATCH 4/7] fix(core): keep getModulesDefinitionFromIntegrationClasses as a deprecated registry wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #679 removed the export. ADR-059 §2 keeps it as a deprecated wrapper so adopters that import it keep working. It now returns the entity type registry's definitions (optionally including app.entities), logs a deprecation warning once per process, and is exported again from @friggframework/core and integrations/. Typed in types/modules. The "registered": false list flag of ADR-059 §4 is not built; the reference doc says so (the ADR is amended on the ADR branch). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- docs/reference/core-concepts.md | 4 + packages/core/index.js | 3 + packages/core/integrations/index.js | 5 + ...efinition-from-integration-classes.test.js | 105 ++++++++++++++++++ .../integrations/utils/map-integration-dto.js | 45 +++++++- packages/core/types/modules/index.d.ts | 10 ++ 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 packages/core/integrations/utils/get-modules-definition-from-integration-classes.test.js diff --git a/docs/reference/core-concepts.md b/docs/reference/core-concepts.md index 6c900fe47..f3cb748ae 100644 --- a/docs/reference/core-concepts.md +++ b/docs/reference/core-concepts.md @@ -79,6 +79,10 @@ const appDefinition = { In code, `getEntityTypeRegistry(appDefinition)` (`packages/core/modules/entity-type-registry.js`, exported from `@friggframework/core`) returns `name -> { definition, sources, settings }`. `sources` holds `{ kind: 'app' }` and one `{ kind: 'integration', name }` per binding integration. Invalid registries throw `EntityRegistryError` (`code: 'ENTITY_REGISTRY_INVALID'`). `createFriggCommands` takes an optional `moduleDefinitions`; without it the commands hydrate with the integration's own modules only. +`getModulesDefinitionFromIntegrationClasses(integrationClasses, entities?)` is deprecated but still exported. It is a wrapper over the registry and returns its definitions, so it follows the registry's rules: one definition per entity type name, and registry-only types when `entities` is passed. It logs `frigg.integrations.deprecated_module_definitions` once per process. + +Not built yet: an entity whose type is no longer offered (unregistered and bound by no integration) still lists, reads and deletes like any other, but carries no `registered: false` flag, and module-backed calls on it (test-auth, options) return `404 ENTITY_TYPE_NOT_FOUND`. + Decision record: ADR-059, app-level entity registry. ## Testing diff --git a/packages/core/index.js b/packages/core/index.js index 75d3fbfcd..dead7e0b8 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -63,6 +63,7 @@ const { Options, createIntegrationRouter, checkRequiredParams, + getModulesDefinitionFromIntegrationClasses, LoadIntegrationContextUseCase, } = require('./integrations/index'); const { @@ -151,6 +152,8 @@ module.exports = { Options, checkRequiredParams, createIntegrationRouter, + // Deprecated (ADR-059): use getEntityTypeRegistry. + getModulesDefinitionFromIntegrationClasses, LoadIntegrationContextUseCase, CreateProcess, UpdateProcessState, diff --git a/packages/core/integrations/index.js b/packages/core/integrations/index.js index 7c7aa140f..3cd8a3e84 100644 --- a/packages/core/integrations/index.js +++ b/packages/core/integrations/index.js @@ -4,6 +4,9 @@ const { createIntegrationRouter, checkRequiredParams, } = require('./integration-router'); +const { + getModulesDefinitionFromIntegrationClasses, +} = require('./utils/map-integration-dto'); const { LoadIntegrationContextUseCase, } = require('./use-cases/load-integration-context'); @@ -18,6 +21,8 @@ module.exports = { Options, createIntegrationRouter, checkRequiredParams, + // Deprecated (ADR-059): a wrapper over getEntityTypeRegistry. + getModulesDefinitionFromIntegrationClasses, LoadIntegrationContextUseCase, validateExtensionBinding, getExtensionRoutes, diff --git a/packages/core/integrations/utils/get-modules-definition-from-integration-classes.test.js b/packages/core/integrations/utils/get-modules-definition-from-integration-classes.test.js new file mode 100644 index 000000000..bb534bdfd --- /dev/null +++ b/packages/core/integrations/utils/get-modules-definition-from-integration-classes.test.js @@ -0,0 +1,105 @@ +/** + * getModulesDefinitionFromIntegrationClasses is kept as a deprecated wrapper + * over the entity type registry (ADR-059 §2), so adopters that import it keep + * working. + */ +const core = require('../../index'); +const integrationsIndex = require('../index'); +const { + getModulesDefinitionFromIntegrationClasses, +} = require('./map-integration-dto'); +const { + getEntityTypeRegistry, + getEntityTypeDefinitions, + EntityRegistryError, +} = require('../../modules/entity-type-registry'); +const { createMemorySink } = require('../../logs'); + +const def = (moduleName, extra = {}) => ({ moduleName, ...extra }); +const integration = (name, modules) => ({ + Definition: { + name, + modules: Object.fromEntries( + Object.entries(modules).map(([key, definition]) => [ + key, + { definition }, + ]) + ), + }, +}); + +const hubspot = def('hubspot'); +const slack = def('slack'); +const google = def('google'); + +describe('getModulesDefinitionFromIntegrationClasses (deprecated wrapper)', () => { + it('is still exported from @friggframework/core and integrations/', () => { + expect(core.getModulesDefinitionFromIntegrationClasses).toBe( + getModulesDefinitionFromIntegrationClasses + ); + expect( + integrationsIndex.getModulesDefinitionFromIntegrationClasses + ).toBe(getModulesDefinitionFromIntegrationClasses); + }); + + it('returns each bound definition once, as before', () => { + const integrations = [ + integration('a', { crm: hubspot, chat: slack }), + integration('b', { crm: hubspot }), + ]; + expect(getModulesDefinitionFromIntegrationClasses(integrations)).toEqual( + [hubspot, slack] + ); + }); + + it('returns exactly the registry definitions for the same integrations', () => { + const first = def('crm', { marker: 'first' }); + const second = def('crm', { marker: 'second' }); + const integrations = [ + integration('a', { crm: first, chat: slack }), + integration('b', { crm: second }), + ]; + const viaWrapper = + getModulesDefinitionFromIntegrationClasses(integrations); + expect(viaWrapper).toEqual( + getEntityTypeDefinitions(getEntityTypeRegistry({ integrations })) + ); + // One definition per entity type name: the first wins, as the + // registry (and ModuleFactory) pick it. + expect(viaWrapper).toEqual([first, slack]); + }); + + it('includes registry-only types when the app entities are passed', () => { + const definitions = getModulesDefinitionFromIntegrationClasses( + [integration('a', { crm: hubspot })], + { google: { definition: google } } + ); + expect(definitions).toEqual([google, hubspot]); + }); + + it('applies the registry conflict rule when entities are passed', () => { + expect(() => + getModulesDefinitionFromIntegrationClasses( + [integration('a', { crm: def('hubspot', { other: true }) })], + { hubspot: { definition: hubspot } } + ) + ).toThrow(EntityRegistryError); + }); + + it('logs a deprecation warning once per process', () => { + jest.isolateModules(() => { + const sink = createMemorySink(); + const fresh = require('./map-integration-dto'); + fresh.getModulesDefinitionFromIntegrationClasses([]); + fresh.getModulesDefinitionFromIntegrationClasses([]); + const warnings = sink.records.filter( + (r) => + r.eventName === + 'frigg.integrations.deprecated_module_definitions' + ); + expect(warnings).toEqual([ + expect.objectContaining({ level: 'WARN' }), + ]); + }); + }); +}); diff --git a/packages/core/integrations/utils/map-integration-dto.js b/packages/core/integrations/utils/map-integration-dto.js index bcc51d7ac..4955677ed 100644 --- a/packages/core/integrations/utils/map-integration-dto.js +++ b/packages/core/integrations/utils/map-integration-dto.js @@ -19,5 +19,48 @@ function mapIntegrationClassToIntegrationDTO(integration) { }; } +let deprecationLogged = false; -module.exports = { mapIntegrationClassToIntegrationDTO }; +/** + * @deprecated Since ADR-059. Read `entityTypeRegistry` / `moduleDefinitions` + * from `loadAppDefinition()`, or call `getEntityTypeRegistry(appDefinition)` + * and read each entry's `definition`. + * + * Kept as a wrapper over the entity type registry so callers that imported it + * keep working. It returns the registry's definitions, so it follows the + * registry's rules: one definition per entity type name (the first wins when + * two integrations bind different definitions under one name, as + * ModuleFactory already picked), and, when `entities` is passed, registry-only + * types are included and conflicts throw `EntityRegistryError`. + * + * @param {Array} integrationClasses - integration classes + * @param {Object} [entities] - `appDefinition.entities` (ADR-059) + * @returns {Array} API module definitions + */ +function getModulesDefinitionFromIntegrationClasses( + integrationClasses, + entities +) { + // Required here, not at the top: modules/ must not load at require time + // of every integration use case that only wants the DTO mapper. + const { + getEntityTypeRegistry, + getEntityTypeDefinitions, + } = require('../../modules/entity-type-registry'); + if (!deprecationLogged) { + deprecationLogged = true; + const { getLogger } = require('../../logs'); + getLogger('frigg.integrations').warn( + 'getModulesDefinitionFromIntegrationClasses is deprecated; use getEntityTypeRegistry (ADR-059)', + { eventName: 'frigg.integrations.deprecated_module_definitions' } + ); + } + return getEntityTypeDefinitions( + getEntityTypeRegistry({ integrations: integrationClasses, entities }) + ); +} + +module.exports = { + mapIntegrationClassToIntegrationDTO, + getModulesDefinitionFromIntegrationClasses, +}; diff --git a/packages/core/types/modules/index.d.ts b/packages/core/types/modules/index.d.ts index c675b801b..b24c0896a 100644 --- a/packages/core/types/modules/index.d.ts +++ b/packages/core/types/modules/index.d.ts @@ -51,6 +51,16 @@ declare module "@friggframework/core" { appDefinition?: Pick ): EntityTypeRegistry; + /** + * @deprecated ADR-059. Use `getEntityTypeRegistry(appDefinition)` and read + * each entry's `definition`. Returns the registry's definitions for these + * integrations (plus registry-only types when `entities` is passed). + */ + export function getModulesDefinitionFromIntegrationClasses( + integrationClasses: any[], + entities?: AppDefinitionEntities + ): any[]; + /** Invalid `entities` registry. `code` is always `ENTITY_REGISTRY_INVALID`. */ export class EntityRegistryError extends Error { code: "ENTITY_REGISTRY_INVALID"; From d8dfd04b91357dab3e89ccb453b066ccbf522e66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:15:07 +0000 Subject: [PATCH 5/7] fix(core): drain the cursor in DocumentDB ownership list reads findEntitiesByOwnership and findIntegrationsByOwnership read only the first cursor batch (findMany returns cursor.firstBatch), so lists past ~101 documents lost rows, the same defect #681 fixed for memberships. Both now use findManyDrained. The bulk credential read behind entity lists is drained too, or entities past the first batch of the $in read came back without their credential. Tested over a fake server holding 2500 documents per collection (more than one getMore); each of the three reads reverted to findMany fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- .../integration-repository-documentdb.js | 3 +- ...documentdb-ownership-drains-cursor.test.js | 148 ++++++++++++++++++ .../module-repository-documentdb.js | 9 +- 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 packages/core/modules/repositories/__tests__/documentdb-ownership-drains-cursor.test.js diff --git a/packages/core/integrations/repositories/integration-repository-documentdb.js b/packages/core/integrations/repositories/integration-repository-documentdb.js index 58af2e9fb..a4d17058d 100644 --- a/packages/core/integrations/repositories/integration-repository-documentdb.js +++ b/packages/core/integrations/repositories/integration-repository-documentdb.js @@ -40,7 +40,8 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface { */ async findIntegrationsByOwnership(clauses) { if (!Array.isArray(clauses) || clauses.length === 0) return []; - const records = await findMany(this.prisma, 'Integration', { + // Drained: a list can hold more than the first cursor batch (~101). + const records = await findManyDrained(this.prisma, 'Integration', { $or: clauses.map(({ userId, organizationId }) => ({ userId: toObjectId(userId), organizationId: diff --git a/packages/core/modules/repositories/__tests__/documentdb-ownership-drains-cursor.test.js b/packages/core/modules/repositories/__tests__/documentdb-ownership-drains-cursor.test.js new file mode 100644 index 000000000..8300f5a0d --- /dev/null +++ b/packages/core/modules/repositories/__tests__/documentdb-ownership-drains-cursor.test.js @@ -0,0 +1,148 @@ +// The ownership list reads (entities and integrations) use the REAL +// documentdb-utils over a fake $runCommandRaw that serves more than one cursor +// batch, so a read that stops at the first batch (~101 documents) fails here. +jest.mock('../../../database/prisma', () => ({ + prisma: { $runCommandRaw: jest.fn() }, +})); +jest.mock('../../../database/documentdb-encryption-service', () => ({ + DocumentDBEncryptionService: class { + async decryptFields(_model, doc) { + return doc; + } + + async encryptFields(_model, doc) { + return doc; + } + }, +})); + +const { ObjectId } = require('bson'); +const { prisma } = require('../../../database/prisma'); +const { + ModuleRepositoryDocumentDB, +} = require('../module-repository-documentdb'); +const { + IntegrationRepositoryDocumentDB, +} = require('../../../integrations/repositories/integration-repository-documentdb'); + +// Past both the 101-document first batch of a plain find and the drain batch +// size of 1000, so the read needs more than one getMore. +const TOTAL = 2500; + +/** + * A server holding documents per collection. A find without batchSize + * returns 101 documents and a cursor (as MongoDB does); a find with batchSize + * returns that many; getMore pages through the rest. + */ +function serveCollections(byCollection) { + const positions = {}; + prisma.$runCommandRaw.mockImplementation(async (command) => { + if (command.find) { + const docs = byCollection[command.find] || []; + const ids = command.filter?._id?.$in; + const wanted = ids && new Set(ids.map((id) => id.toHexString())); + const matching = wanted + ? docs.filter((d) => wanted.has(d._id.toHexString())) + : docs; + const size = command.batchSize + ? Math.min(command.batchSize, matching.length) + : Math.min(101, matching.length); + positions[command.find] = { docs: matching, position: size }; + return { + ok: 1, + cursor: { + id: size >= matching.length ? 0 : 77, + firstBatch: matching.slice(0, size), + }, + }; + } + if (command.getMore) { + const state = positions[command.collection]; + const batch = state.docs.slice( + state.position, + state.position + command.batchSize + ); + state.position += batch.length; + return { + ok: 1, + cursor: { + id: state.position >= state.docs.length ? 0 : 77, + nextBatch: batch, + }, + }; + } + throw new Error(`unexpected command ${JSON.stringify(command)}`); + }); +} + +const getMoreCalls = (collection) => + prisma.$runCommandRaw.mock.calls.filter( + ([c]) => c.getMore && c.collection === collection + ).length; + +beforeEach(() => jest.clearAllMocks()); + +describe('DocumentDB ownership lists follow the cursor', () => { + const ownerOid = new ObjectId(); + const orgOid = new ObjectId(); + const clauses = [ + { + userId: ownerOid.toHexString(), + organizationId: orgOid.toHexString(), + }, + ]; + + it('findEntitiesByOwnership returns every entity, each with its credential', async () => { + const credentials = Array.from({ length: TOTAL }, () => ({ + _id: new ObjectId(), + userId: ownerOid, + authIsValid: true, + data: { accessToken: 'x' }, + })); + const entities = credentials.map((credential, i) => ({ + _id: new ObjectId(), + userId: ownerOid, + organizationId: orgOid, + credentialId: credential._id, + moduleName: 'hubspot', + name: `e${i}`, + externalId: `x${i}`, + })); + serveCollections({ Entity: entities, Credential: credentials }); + + const result = + await new ModuleRepositoryDocumentDB().findEntitiesByOwnership( + clauses + ); + + expect(result).toHaveLength(TOTAL); + expect(result[TOTAL - 1].name).toBe(`e${TOTAL - 1}`); + // The bulk credential read is drained too: every entity keeps its + // credential, not only those in the first batch of the $in read. + expect(result.every((e) => e.credential)).toBe(true); + expect(getMoreCalls('Entity')).toBeGreaterThan(0); + expect(getMoreCalls('Credential')).toBeGreaterThan(0); + }); + + it('findIntegrationsByOwnership returns every integration', async () => { + const integrations = Array.from({ length: TOTAL }, (_, i) => ({ + _id: new ObjectId(), + userId: ownerOid, + organizationId: orgOid, + entityIds: [], + config: { type: 'sync', n: i }, + status: 'ENABLED', + version: '1.0.0', + })); + serveCollections({ Integration: integrations }); + + const result = + await new IntegrationRepositoryDocumentDB().findIntegrationsByOwnership( + clauses + ); + + expect(result).toHaveLength(TOTAL); + expect(result[TOTAL - 1].config.n).toBe(TOTAL - 1); + expect(getMoreCalls('Integration')).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/modules/repositories/module-repository-documentdb.js b/packages/core/modules/repositories/module-repository-documentdb.js index 714fb3109..2b139a71d 100644 --- a/packages/core/modules/repositories/module-repository-documentdb.js +++ b/packages/core/modules/repositories/module-repository-documentdb.js @@ -3,6 +3,7 @@ const { toObjectId, fromObjectId, findMany, + findManyDrained, findOne, insertOne, updateOne, @@ -87,7 +88,8 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface { */ async findEntitiesByOwnership(clauses) { if (!Array.isArray(clauses) || clauses.length === 0) return []; - const docs = await findMany(this.prisma, 'Entity', { + // Drained: a list can hold more than the first cursor batch (~101). + const docs = await findManyDrained(this.prisma, 'Entity', { $or: clauses.map(({ userId, organizationId }) => ({ userId: toObjectId(userId), organizationId: @@ -277,8 +279,9 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface { const objectIds = ids.map(id => toObjectId(id)).filter(Boolean); if (objectIds.length === 0) return new Map(); - // Use raw findMany to bypass Prisma encryption extension - const rawCredentials = await findMany(this.prisma, 'Credential', { + // Raw read to bypass the Prisma encryption extension. Drained: + // more ids than the first cursor batch must all come back. + const rawCredentials = await findManyDrained(this.prisma, 'Credential', { _id: { $in: objectIds } }); From d8626c44a041edbf421a843c24d6a050cbf8657b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:19:02 +0000 Subject: [PATCH 6/7] fix(core): v1 POST /api/entity resolves its owner by scope; v1 entity routes build the module for the owner POST /api/entity took user.getId() as the owner and set no organizationId, so it fell outside the ownership rules (an org-primary member could create an org-owned row). It also called Module.findOrCreateEntity, which no longer exists, so it failed on every call. It now goes through a new CreateEntityFromCredential use case: the owner comes from AuthorizeEntityForUser.resolveOwnership (as /api/authorize), the credential must belong to that owner (400 INVALID_CREDENTIAL otherwise, the same for a missing one), the module is built with the credential loaded, and the entity is found or created with the resolved organization. TestModuleAuth, GetModule, GetEntityOptionsById and RefreshEntityOptions (v1 test-auth, GET /api/entities/:id and the options routes) built the Module with the caller's id. A member using an organization's entity there would, on a token refresh, upsert a new credential under the member. They now build it for entity.userId, as v2 does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- docs/guides/entity-scopes.md | 10 + .../core/integrations/integration-router.js | 54 +++-- .../integration-router-ownership.test.js | 29 +++ .../__tests__/v1-entity-owner.test.js | 191 ++++++++++++++++++ .../create-entity-from-credential.js | 113 +++++++++++ .../use-cases/get-entity-options-by-id.js | 5 +- packages/core/modules/use-cases/get-module.js | 5 +- .../use-cases/refresh-entity-options.js | 5 +- .../modules/use-cases/test-module-auth.js | 5 +- 9 files changed, 385 insertions(+), 32 deletions(-) create mode 100644 packages/core/modules/use-cases/__tests__/v1-entity-owner.test.js create mode 100644 packages/core/modules/use-cases/create-entity-from-credential.js diff --git a/docs/guides/entity-scopes.md b/docs/guides/entity-scopes.md index c41d6a1d8..d6dc77cc7 100644 --- a/docs/guides/entity-scopes.md +++ b/docs/guides/entity-scopes.md @@ -61,6 +61,16 @@ This replaces `user.getId()` as the owner. `POST /api/v2/authorize` and the v1 ` through `AuthorizeEntityForUser`. The module's `getEntityDetails` and `getCredentialDetails` get the **owner's** id as their user id, and the credential is stored under it. +The v1 `POST /api/entity` (an entity for a credential the caller already holds) resolves its owner the same way, +through `CreateEntityFromCredential`, which calls `AuthorizeEntityForUser.resolveOwnership`. The credential must +belong to that owner (the organization's, for an organization type), or the answer is 400 `INVALID_CREDENTIAL`, the +same for a missing credential and a stranger's. Before this change the route took `user.getId()` and set no +organization; it also called a `findOrCreateEntity` that `Module` no longer has, so it failed on every call. + +The v1 test-auth, `GET /api/entities/:id` and the two entity options routes build the `Module` for the entity's +owner (`entity.userId`), as v2 does, not for the caller. A member may use an organization's entity there, and a +token refresh during such a call updates the organization's credential instead of creating one under the member. + **An integration must use entities of one scope.** Its owner is the organization when every entity is organization-scoped, otherwise the person (or what `Definition.ownership.owner` says). An integration is loaded by its owner, who must own what it uses, so `ResolveIntegrationOwnership` refuses, with a 400 and **before anything is diff --git a/packages/core/integrations/integration-router.js b/packages/core/integrations/integration-router.js index 2743b115c..77430086c 100644 --- a/packages/core/integrations/integration-router.js +++ b/packages/core/integrations/integration-router.js @@ -65,6 +65,9 @@ const { createScopePolicy } = require('../modules/scope-policy'); const { AuthorizeEntityForUser, } = require('../modules/use-cases/authorize-entity-for-user'); +const { + CreateEntityFromCredential, +} = require('../modules/use-cases/create-entity-from-credential'); const { ResolveIntegrationOwnership, } = require('./use-cases/resolve-integration-ownership'); @@ -199,14 +202,22 @@ function createIntegrationRouter() { }); // The owner of what an authorization creates follows its scope (ADR-056 §3.2). + const processAuthorizationCallback = new ProcessAuthorizationCallback({ + moduleRepository, + credentialRepository, + integrationRepository, + moduleDefinitions, + }); const authorizeEntityForUser = new AuthorizeEntityForUser({ scopePolicy, - processAuthorizationCallback: new ProcessAuthorizationCallback({ - moduleRepository, - credentialRepository, - integrationRepository, - moduleDefinitions, - }), + processAuthorizationCallback, + }); + // v1 POST /api/entity: same owner resolution as /api/authorize. + const createEntityFromCredential = new CreateEntityFromCredential({ + authorizeEntityForUser, + processAuthorizationCallback, + credentialRepository, + moduleDefinitions, }); const router = express(); @@ -231,6 +242,7 @@ function createIntegrationRouter() { getEntityOptionsById, refreshEntityOptions, authorizeEntityForUser, + createEntityFromCredential, }); return router; } @@ -527,6 +539,7 @@ function setEntityRoutes(router, authenticateUser, useCases) { getEntityOptionsById, refreshEntityOptions, authorizeEntityForUser, + createEntityFromCredential, } = useCases; router.route('/api/authorize').get( @@ -601,35 +614,20 @@ function setEntityRoutes(router, authenticateUser, useCases) { router.route('/api/entity').post( catchAsyncError(async (req, res) => { const user = await authenticateUser.execute(req); - const userId = user.getId(); const params = checkRequiredParams(req.body, [ 'entityType', 'data', ]); checkRequiredParams(req.body.data, ['credential_id']); - // May want to pass along the user ID as well so credential ID's can't be fished??? - const credential = await getCredentialForUser.execute( - params.data.credential_id, - userId - ); - - if (!credential) { - throw Boom.badRequest('Invalid credential ID'); - } - - const module = await getModuleInstanceFromType.execute( - userId, - params.entityType - ); - const entityDetails = await module.getEntityDetails( - module.api, - null, - null, - userId + // The owner follows the entity type's scope, as /api/authorize. + res.json( + await createEntityFromCredential.execute({ + user, + entityType: params.entityType, + credentialId: params.data.credential_id, + }) ); - - res.json(await module.findOrCreateEntity(entityDetails)); }) ); diff --git a/packages/core/integrations/tests/integration-router-ownership.test.js b/packages/core/integrations/tests/integration-router-ownership.test.js index e2de31d51..6d0031136 100644 --- a/packages/core/integrations/tests/integration-router-ownership.test.js +++ b/packages/core/integrations/tests/integration-router-ownership.test.js @@ -54,6 +54,7 @@ const { AuthenticateUser } = require('../../user/use-cases/authenticate-user'); const { User } = require('../../user/user'); const { ProcessAuthorizationCallback } = require('../../modules/use-cases/process-authorization-callback'); const { GetModuleInstanceFromType } = require('../../modules/use-cases/get-module-instance-from-type'); +const { CreateEntityFromCredential } = require('../../modules/use-cases/create-entity-from-credential'); const { CreateIntegration } = require('../use-cases/create-integration'); const { GetIntegrationsForUser } = require('../use-cases/get-integrations-for-user'); const { GetIntegrationInstance } = require('../use-cases/get-integration-instance'); @@ -132,6 +133,34 @@ describe('v1 routes follow the ownership rules', () => { expect(post.mock.calls[0][0]).toBe('17'); }); + it('POST /api/entity goes through CreateEntityFromCredential with the caller, not user.getId()', async () => { + const execute = jest + .spyOn(CreateEntityFromCredential.prototype, 'execute') + .mockResolvedValue({ id: 'e1' }); + const res = await call('POST', '/api/entity', { + entityType: 'acme', + data: { credential_id: 'c1' }, + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: 'e1' }); + expect(execute).toHaveBeenCalledWith({ + user: currentUser, + entityType: 'acme', + credentialId: 'c1', + }); + }); + + it('POST /api/entity: 400 IDENTITY_REQUIRED when the scope needs an organization the request does not have', async () => { + const findOrCreate = jest.spyOn(ProcessAuthorizationCallback.prototype, 'findOrCreateEntity'); + currentUser = caller(); + const res = await call('POST', '/api/entity', { + entityType: 'acme', + data: { credential_id: 'c1' }, + }); + expect(res.status).toBe(400); + expect(findOrCreate).not.toHaveBeenCalled(); + }); + it('POST /api/authorize: 400 when the scope needs an organization the request does not have', async () => { const execute = jest.spyOn(ProcessAuthorizationCallback.prototype, 'execute'); currentUser = caller(); diff --git a/packages/core/modules/use-cases/__tests__/v1-entity-owner.test.js b/packages/core/modules/use-cases/__tests__/v1-entity-owner.test.js new file mode 100644 index 000000000..bd14f488c --- /dev/null +++ b/packages/core/modules/use-cases/__tests__/v1-entity-owner.test.js @@ -0,0 +1,191 @@ +/** + * The v1 entity routes decide the owner by scope, as /api/authorize does + * (ADR-056 §3.2), and build a Module for the entity's OWNER, so a token + * refresh never writes a credential under the caller. + * + * The Module is the only fake; the scope policy, AuthorizeEntityForUser and + * ProcessAuthorizationCallback.findOrCreateEntity are real. + */ +jest.mock('../../module', () => { + const Module = jest.fn().mockImplementation(({ userId, definition, entity }) => ({ + userId, + entity, + credential: entity?.credential, + definition, + api: { tokens: entity?.credential?.data }, + testAuth: jest.fn().mockResolvedValue(true), + apiParamsFromEntity: jest.fn().mockReturnValue({}), + getEntityOptions: jest.fn().mockReturnValue({ options: [] }), + refreshEntityOptions: jest.fn().mockResolvedValue({ options: [] }), + })); + Module.getEntityModelFromDefinition = (def) => ({ modelName: def.moduleName }); + return { Module }; +}); + +const { Module } = require('../../module'); +const { User } = require('../../../user/user'); +const { createScopePolicy } = require('../../scope-policy'); +const { AuthorizeEntityForUser } = require('../authorize-entity-for-user'); +const { ProcessAuthorizationCallback } = require('../process-authorization-callback'); +const { CreateEntityFromCredential } = require('../create-entity-from-credential'); +const { TestModuleAuth } = require('../test-module-auth'); +const { GetModule } = require('../get-module'); +const { GetEntityOptionsById } = require('../get-entity-options-by-id'); +const { RefreshEntityOptions } = require('../refresh-entity-options'); + +const caller = ({ org = null, primary = 'individual' } = {}) => + new User({ id: '7', organizationId: null }, org ? { id: String(org) } : null, false, primary, true, false); + +function definition(calls) { + return { + moduleName: 'acme', + getName: () => 'acme', + API: class {}, + requiredAuthMethods: { + getEntityDetails: jest.fn(async (api, _p, _t, userId) => { + calls.push({ userId, tokens: api.tokens }); + return { + identifiers: { user: userId, externalId: 'acct-1' }, + details: { name: 'Acme' }, + }; + }), + }, + }; +} + +function buildCreate({ registry, credential } = {}) { + const calls = []; + const moduleDefinitions = [definition(calls)]; + const moduleRepository = { + findEntities: jest.fn(async () => []), + createEntity: jest.fn(async (data, tenancy) => ({ id: 'new', ...data, tenancy })), + updateEntity: jest.fn(), + }; + const credentialRepository = { + findCredentialById: jest.fn(async () => credential), + }; + const processAuthorizationCallback = new ProcessAuthorizationCallback({ + moduleRepository, + credentialRepository, + moduleDefinitions, + }); + const scopePolicy = createScopePolicy({ entityTypeRegistry: registry }); + const useCase = new CreateEntityFromCredential({ + authorizeEntityForUser: new AuthorizeEntityForUser({ + scopePolicy, + processAuthorizationCallback, + }), + processAuthorizationCallback, + credentialRepository, + moduleDefinitions, + }); + return { useCase, moduleRepository, calls }; +} + +const create = (t, user) => + t.useCase.execute({ user, entityType: 'acme', credentialId: 'c1' }); + +const credentialOf = (userId) => ({ + id: 'c1', + userId, + externalId: 'acct-1', + authIsValid: true, + access_token: 'tok', +}); + +beforeEach(() => jest.clearAllMocks()); + +describe('POST /api/entity (CreateEntityFromCredential)', () => { + const orgScoped = new Map([['acme', { settings: { scope: 'organization' } }]]); + + it('an individual type: owned by the person, paired with the active organization', async () => { + const t = buildCreate({ credential: credentialOf('7') }); + await create(t, caller({ org: 17 })); + const [data, tenancy] = t.moduleRepository.createEntity.mock.calls[0]; + expect(data.user).toBe('7'); + expect(data.credential).toBe('c1'); + expect(tenancy).toEqual({ organizationId: '17' }); + expect(Module.mock.calls[0][0].userId).toBe('7'); + }); + + it('an organization type: owned by the active organization, not by user.getId()', async () => { + const t = buildCreate({ registry: orgScoped, credential: credentialOf('17') }); + await create(t, caller({ org: 17 })); + const [data, tenancy] = t.moduleRepository.createEntity.mock.calls[0]; + expect(data.user).toBe('17'); + expect(tenancy).toEqual({ organizationId: '17' }); + expect(t.calls[0].userId).toBe('17'); + expect(Module.mock.calls[0][0].userId).toBe('17'); + }); + + it('loads the credential into the module, so the provider call is authenticated', async () => { + const t = buildCreate({ credential: credentialOf('7') }); + await create(t, caller({ org: 17 })); + expect(t.calls[0].tokens).toEqual({ access_token: 'tok' }); + }); + + it("refuses a credential that is not the resolved owner's (the person's own, for an organization type)", async () => { + const t = buildCreate({ registry: orgScoped, credential: credentialOf('7') }); + await expect(create(t, caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 400 }, + data: { code: 'INVALID_CREDENTIAL' }, + }); + expect(t.moduleRepository.createEntity).not.toHaveBeenCalled(); + }); + + it('a missing credential and a stranger’s get the same 400', async () => { + for (const credential of [null, credentialOf('99')]) { + const t = buildCreate({ credential }); + await expect(create(t, caller({ org: 17 }))).rejects.toMatchObject({ + output: { statusCode: 400 }, + data: { code: 'INVALID_CREDENTIAL' }, + }); + } + }); + + it('400 IDENTITY_REQUIRED when the scope needs an organization the request lacks', async () => { + const t = buildCreate({ registry: orgScoped, credential: credentialOf('17') }); + await expect(create(t, caller())).rejects.toMatchObject({ + output: { statusCode: 400 }, + data: { code: 'IDENTITY_REQUIRED' }, + }); + expect(t.moduleRepository.createEntity).not.toHaveBeenCalled(); + }); + + it('an unknown entity type is a 400', async () => { + const t = buildCreate({ credential: credentialOf('7') }); + await expect( + t.useCase.execute({ user: caller(), entityType: 'nope', credentialId: 'c1' }) + ).rejects.toMatchObject({ output: { statusCode: 400 } }); + }); +}); + +describe('v1 entity reads build the module for the entity owner', () => { + const orgEntity = { + id: '5', + userId: '17', + organizationId: '17', + moduleName: 'acme', + name: 'Acme', + user: '17', + credential: { id: 'c1', userId: '17', data: {} }, + }; + const deps = () => ({ + moduleRepository: { findEntityById: jest.fn(async () => orgEntity) }, + moduleDefinitions: [definition([])], + scopePolicy: createScopePolicy({}), + }); + // A member of organization 17 (individual-primary: getId() is the person). + const member = () => caller({ org: 17 }); + + it.each([ + ['TestModuleAuth', (d) => new TestModuleAuth(d).execute('5', member())], + ['GetModule', (d) => new GetModule(d).execute('5', member())], + ['GetEntityOptionsById', (d) => new GetEntityOptionsById(d).execute('5', member())], + ['RefreshEntityOptions', (d) => new RefreshEntityOptions(d).execute('5', member(), {})], + ])('%s: Module userId is the owner (17), not the caller (7)', async (_name, run) => { + await run(deps()); + expect(Module).toHaveBeenCalledTimes(1); + expect(Module.mock.calls[0][0].userId).toBe('17'); + }); +}); diff --git a/packages/core/modules/use-cases/create-entity-from-credential.js b/packages/core/modules/use-cases/create-entity-from-credential.js new file mode 100644 index 000000000..a8cdad1ea --- /dev/null +++ b/packages/core/modules/use-cases/create-entity-from-credential.js @@ -0,0 +1,113 @@ +const Boom = require('@hapi/boom'); +const { Module } = require('../module'); + +const sameId = (a, b) => + a !== undefined && a !== null && b !== undefined && b !== null && + String(a) === String(b); + +/** + * Creates (or finds) the entity for a credential the caller already holds: + * the v1 `POST /api/entity`. The owner and organization follow the scope of + * `entityType`, exactly as an authorization does (ADR-056 §3.2, §3.6), so the + * owner is decided by `AuthorizeEntityForUser.resolveOwnership` and never by + * `user.getId()`. Whatever that resolution refuses (a missing identity, and + * an organization-level connection by a non-admin) is refused here too. + * + * The credential must belong to that owner: an organization entity is built + * on the organization's credential, an individual one on the person's own. + */ +class CreateEntityFromCredential { + /** + * @param {Object} params + * @param {import('./authorize-entity-for-user').AuthorizeEntityForUser} params.authorizeEntityForUser + * @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback + * @param {Object} params.credentialRepository + * @param {Array} params.moduleDefinitions + * @param {Function} [params.createModule] - builds a Module; injectable for tests + */ + constructor({ + authorizeEntityForUser, + processAuthorizationCallback, + credentialRepository, + moduleDefinitions, + createModule, + }) { + this.authorizeEntityForUser = authorizeEntityForUser; + this.processAuthorizationCallback = processAuthorizationCallback; + this.credentialRepository = credentialRepository; + this.moduleDefinitions = moduleDefinitions || []; + this.createModule = createModule || ((params) => new Module(params)); + } + + /** + * @param {Object} params + * @param {Object} params.user - authenticated User + * @param {string} params.entityType + * @param {string} params.credentialId + * @returns {Promise} the persisted entity + */ + async execute({ user, entityType, credentialId }) { + const definition = this.moduleDefinitions.find( + (def) => + def.moduleName === entityType || + (typeof def.getName === 'function' && def.getName() === entityType) + ); + if (!definition) { + throw Boom.badRequest(`Unknown entity type ${entityType}`, { + code: 'ENTITY_TYPE_NOT_FOUND', + }); + } + + const ownership = this.authorizeEntityForUser.resolveOwnership({ + user, + entityType, + }); + + const credential = + await this.credentialRepository.findCredentialById(credentialId); + // A missing credential and someone else's are the same answer, so ids + // cannot be probed. + if (!credential || !sameId(credential.userId, ownership.ownerId)) { + throw Boom.badRequest('Invalid credential ID', { + code: 'INVALID_CREDENTIAL', + }); + } + + // Built for the owner, with the credential loaded (the repository + // returns its tokens flat; a Module reads them from `data`), so the + // API can call the provider and a token refresh during + // getEntityDetails updates the owner's credential. + const { id, userId, externalId, authIsValid, ...data } = credential; + const module = this.createModule({ + userId: ownership.ownerId, + definition, + entity: { + credential: { id, userId, externalId, authIsValid, data }, + }, + }); + const entityDetails = + await definition.requiredAuthMethods.getEntityDetails( + module.api, + null, + null, + ownership.ownerId + ); + Object.assign( + entityDetails.details, + module.apiParamsFromEntity(module.api) + ); + + return this.processAuthorizationCallback.findOrCreateEntity( + entityDetails, + definition.moduleName, + credential.id, + { + scope: ownership.scope, + organizationId: ownership.organizationId, + legacyOrgId: ownership.legacyOrgId, + } + ); + } +} + +module.exports = { CreateEntityFromCredential }; diff --git a/packages/core/modules/use-cases/get-entity-options-by-id.js b/packages/core/modules/use-cases/get-entity-options-by-id.js index c2c1fe631..e5de1a45e 100644 --- a/packages/core/modules/use-cases/get-entity-options-by-id.js +++ b/packages/core/modules/use-cases/get-entity-options-by-id.js @@ -60,8 +60,11 @@ class GetEntityOptionsById { ); } + // Built for the entity's OWNER, not the caller: a caller may use an + // organization's entity, and a token refresh during this call must + // update the owner's credential, not create one under the caller. const module = new Module({ - userId, + userId: entity.userId ?? userId, entity, definition: moduleDefinition, }); diff --git a/packages/core/modules/use-cases/get-module.js b/packages/core/modules/use-cases/get-module.js index f2dd1c1be..936f6d558 100644 --- a/packages/core/modules/use-cases/get-module.js +++ b/packages/core/modules/use-cases/get-module.js @@ -56,8 +56,11 @@ class GetModule { ); } + // Built for the entity's OWNER, not the caller: a caller may use an + // organization's entity, and a token refresh during this call must + // update the owner's credential, not create one under the caller. const module = new Module({ - userId, + userId: entity.userId ?? userId, entity, definition: moduleDefinition, }); diff --git a/packages/core/modules/use-cases/refresh-entity-options.js b/packages/core/modules/use-cases/refresh-entity-options.js index 7a6064bf6..ef82d2a84 100644 --- a/packages/core/modules/use-cases/refresh-entity-options.js +++ b/packages/core/modules/use-cases/refresh-entity-options.js @@ -61,8 +61,11 @@ class RefreshEntityOptions { ); } + // Built for the entity's OWNER, not the caller: a caller may use an + // organization's entity, and a token refresh during this call must + // update the owner's credential, not create one under the caller. const module = new Module({ - userId, + userId: entity.userId ?? userId, entity, definition: moduleDefinition, }); diff --git a/packages/core/modules/use-cases/test-module-auth.js b/packages/core/modules/use-cases/test-module-auth.js index c348fa15e..e9d7af995 100644 --- a/packages/core/modules/use-cases/test-module-auth.js +++ b/packages/core/modules/use-cases/test-module-auth.js @@ -60,8 +60,11 @@ class TestModuleAuth { ); } + // Built for the entity's OWNER, not the caller: a caller may use an + // organization's entity, and a token refresh during this call must + // update the owner's credential, not create one under the caller. const module = new Module({ - userId, + userId: entity.userId ?? userId, entity, definition: moduleDefinition, }); From 451c957b188ef7010eecc3c99e0f7ed91e506825 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 04:34:00 +0000 Subject: [PATCH 7/7] fix(core): never return an entity's credential in entity or integration responses The repositories attach the decrypted credential to every entity they return, and three responses sent those records as they were, so a member of an organization received the organization connection's access_token, refresh_token or api_key: - v1 GET /api/integrations (entities.authorized): mapModuleClassToModuleDTO returned the entity record; - v1 GET /api/integrations and v2 GET /api/v2/integrations: the integration DTOs embedded the entities from findEntitiesByIds; - v1 GET /api/entities/:id: GetModule returned module.credential. One shared toPublicEntity (modules/utils/public-entity.js) now decides what an entity looks like in any response: descriptive fields plus credentialId and authIsValid, for every caller including the owner. It is used by the module DTO, GetModule, the v1 integration DTO, the v2 toIntegrationDto, v1 GET /api/integrations/:id and the POST /api/entity response. GetModule, TestModuleAuth, GetEntityOptionsById and RefreshEntityOptions also looked up the module definition with Module.getEntityModelFromDefinition, which does not exist, so every /api/entities/:entityId route answered 500. They now use findModuleDefinitionForEntity, ported unchanged from #667 (f74cf3b1). management-api/credential-exposure.test.js drives the real v1 and v2 routers as an organization member, with repositories that attach the decrypted credential, and asserts no token, key or credential object appears in any entity or integration response. On the previous commit it fails on the three leaks above. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6 --- .../core/integrations/integration-router.js | 21 +- .../integration-router-ownership.test.js | 2 +- .../get-integration-for-user.test.js | 2 +- .../get-integrations-for-user.test.js | 2 +- .../integrations/utils/map-integration-dto.js | 5 +- .../credential-exposure.test.js | 257 ++++++++++++++++++ packages/core/management-api/v2/dto.js | 5 +- packages/core/management-api/v2/dto.test.js | 21 +- .../use-cases/get-entity-options-by-id.js | 12 +- packages/core/modules/use-cases/get-module.js | 26 +- .../use-cases/refresh-entity-options.js | 12 +- .../modules/use-cases/test-module-auth.js | 12 +- .../modules/utils/find-module-definition.js | 23 ++ packages/core/modules/utils/map-module-dto.js | 8 +- packages/core/modules/utils/public-entity.js | 59 ++++ .../core/modules/utils/public-entity.test.js | 52 ++++ 16 files changed, 481 insertions(+), 38 deletions(-) create mode 100644 packages/core/management-api/credential-exposure.test.js create mode 100644 packages/core/modules/utils/find-module-definition.js create mode 100644 packages/core/modules/utils/public-entity.js create mode 100644 packages/core/modules/utils/public-entity.test.js diff --git a/packages/core/integrations/integration-router.js b/packages/core/integrations/integration-router.js index 77430086c..ed5ef2e59 100644 --- a/packages/core/integrations/integration-router.js +++ b/packages/core/integrations/integration-router.js @@ -39,6 +39,10 @@ const { } = require('../modules/use-cases/get-entity-options-by-type'); const { TestModuleAuth } = require('../modules/use-cases/test-module-auth'); const { GetModule } = require('../modules/use-cases/get-module'); +const { + toPublicEntity, + toPublicEntities, +} = require('../modules/utils/public-entity'); const { GetEntityOptionsById, } = require('../modules/use-cases/get-entity-options-by-id'); @@ -487,7 +491,8 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { res.json({ id: integration.id, - entities: integration.entities, + // Entity records carry the decrypted credential. + entities: toPublicEntities(integration.entities), status: integration.status, config: integration.config, }); @@ -621,13 +626,13 @@ function setEntityRoutes(router, authenticateUser, useCases) { checkRequiredParams(req.body.data, ['credential_id']); // The owner follows the entity type's scope, as /api/authorize. - res.json( - await createEntityFromCredential.execute({ - user, - entityType: params.entityType, - credentialId: params.data.credential_id, - }) - ); + const entity = await createEntityFromCredential.execute({ + user, + entityType: params.entityType, + credentialId: params.data.credential_id, + }); + // The persisted entity carries the decrypted credential. + res.json(toPublicEntity(entity)); }) ); diff --git a/packages/core/integrations/tests/integration-router-ownership.test.js b/packages/core/integrations/tests/integration-router-ownership.test.js index 6d0031136..483651600 100644 --- a/packages/core/integrations/tests/integration-router-ownership.test.js +++ b/packages/core/integrations/tests/integration-router-ownership.test.js @@ -142,7 +142,7 @@ describe('v1 routes follow the ownership rules', () => { data: { credential_id: 'c1' }, }); expect(res.status).toBe(200); - expect(res.body).toEqual({ id: 'e1' }); + expect(res.body).toMatchObject({ id: 'e1', credentialId: null }); expect(execute).toHaveBeenCalledWith({ user: currentUser, entityType: 'acme', diff --git a/packages/core/integrations/tests/use-cases/get-integration-for-user.test.js b/packages/core/integrations/tests/use-cases/get-integration-for-user.test.js index e34b2a1cf..ce64105bc 100644 --- a/packages/core/integrations/tests/use-cases/get-integration-for-user.test.js +++ b/packages/core/integrations/tests/use-cases/get-integration-for-user.test.js @@ -50,7 +50,7 @@ describe('GetIntegrationForUser Use-Case', () => { const record = await integrationRepository.createIntegration([entity1.id, entity2.id], 'user-1', { type: 'dummy' }); const dto = await useCase.execute(record.id, 'user-1'); - expect(dto.entities).toEqual([entity1, entity2]); + expect(dto.entities.map((e) => e.id)).toEqual(['entity-1', 'entity-2']); }); it('returns integration with complex config', async () => { diff --git a/packages/core/integrations/tests/use-cases/get-integrations-for-user.test.js b/packages/core/integrations/tests/use-cases/get-integrations-for-user.test.js index be41b766f..2090ae332 100644 --- a/packages/core/integrations/tests/use-cases/get-integrations-for-user.test.js +++ b/packages/core/integrations/tests/use-cases/get-integrations-for-user.test.js @@ -169,7 +169,7 @@ describe('GetIntegrationsByUserId Use-Case (app-level commands, no request)', () await integrationRepository.createIntegration([entity1.id, entity2.id, entity3.id], 'user-1', { type: 'dummy' }); const list = await useCase.execute('user-1'); - expect(list[0].entities).toEqual([entity1, entity2, entity3]); + expect(list[0].entities.map((e) => e.id)).toEqual(['entity-1', 'entity-2', 'entity-3']); }); }); }); \ No newline at end of file diff --git a/packages/core/integrations/utils/map-integration-dto.js b/packages/core/integrations/utils/map-integration-dto.js index 4955677ed..aa03fe9c2 100644 --- a/packages/core/integrations/utils/map-integration-dto.js +++ b/packages/core/integrations/utils/map-integration-dto.js @@ -1,3 +1,5 @@ +const { toPublicEntities } = require('../../modules/utils/public-entity'); + /** * @param {import('../integration').Integration} integration * Convert an Integration domain instance to a plain DTO suitable for JSON responses. @@ -9,7 +11,8 @@ function mapIntegrationClassToIntegrationDTO(integration) { return { id: integration.id, userId: integration.userId, - entities: integration.entities, + // Entity records carry the decrypted credential; never send them as is. + entities: toPublicEntities(integration.entities), config: integration.config, status: integration.status, version: integration.version, diff --git a/packages/core/management-api/credential-exposure.test.js b/packages/core/management-api/credential-exposure.test.js new file mode 100644 index 000000000..406a910e3 --- /dev/null +++ b/packages/core/management-api/credential-exposure.test.js @@ -0,0 +1,257 @@ +/** + * No credential material in any entity or integration response, v1 or v2. + * + * The repositories attach the decrypted credential to every entity they + * return, so an entity record sent as it is hands its tokens to whoever can + * see the entity. For an organization's connection that is every member. + * This drives the real v1 and v2 routers and use cases as a member of the + * organization that owns the connection; the repositories are the only + * fakes, and they return entities the way the real ones do, with the + * decrypted credential attached. + */ +const SECRETS = { + access_token: 'AT-org-secret-0001', + refresh_token: 'RT-org-secret-0002', + api_key: 'KEY-org-secret-0003', + id_token: 'IDT-org-secret-0004', + client_secret: 'CS-org-secret-0005', +}; +const CREDENTIAL_KEYS = Object.keys(SECRETS); + +jest.mock('../modules/module', () => ({ + Module: jest.fn().mockImplementation(({ userId, definition, entity }) => ({ + userId, + entity, + // As the real Module: the credential it was built on, decrypted. + credential: entity?.credential, + definition, + apiClass: { requesterType: 'apiKey' }, + api: { delegate: null }, + name: entity?.moduleName, + testAuth: jest.fn().mockResolvedValue(true), + apiParamsFromCredential: jest.fn().mockReturnValue({}), + apiParamsFromEntity: jest.fn().mockReturnValue({}), + getName: jest.fn().mockReturnValue(entity?.moduleName ?? 'acme'), + })), +})); + +const mockState = { entities: [], integrations: [] }; +const mockSame = (a, b) => + a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b); +const mockMatches = (row, clauses) => + require('../modules/tests/doubles/ownership-matching').matchesAnyClause(row, clauses); + +jest.mock('../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: () => ({ + findEntitiesByOwnership: async (clauses) => + mockState.entities.filter((row) => mockMatches(row, clauses)), + findEntityById: async (id) => { + const row = mockState.entities.find((e) => String(e.id) === String(id)); + if (!row) throw new Error(`Entity ${id} not found`); + return row; + }, + findEntitiesByIds: async (ids) => + mockState.entities.filter((e) => ids.map(String).includes(String(e.id))), + findEntitiesByUserId: async (userId) => + mockState.entities.filter((e) => mockSame(e.userId, userId)), + findEntitiesByUserIdAndModuleName: async (userId, moduleName) => + mockState.entities.filter( + (e) => mockSame(e.userId, userId) && e.moduleName === moduleName + ), + findEntities: async () => mockState.entities, + findEntity: async () => null, + }), +})); +jest.mock('../credential/repositories/credential-repository-factory', () => ({ + createCredentialRepository: () => ({ + findCredentialsByUserId: async () => [], + findCredentialById: async () => null, + }), +})); +jest.mock('../integrations/repositories/integration-repository-factory', () => ({ + createIntegrationRepository: () => ({ + findIntegrationsByOwnership: async (clauses) => + mockState.integrations.filter((row) => mockMatches(row, clauses)), + findIntegrationById: async (id) => { + const row = mockState.integrations.find((i) => String(i.id) === String(id)); + if (!row) throw new Error(`Integration with id ${id} not found`); + return row; + }, + findIntegrationsByUserId: async (userId) => + mockState.integrations.filter((i) => mockSame(i.userId, userId)), + findIntegrationsByEntityId: async () => [], + }), +})); +jest.mock('../user/repositories/user-repository-factory', () => ({ + createUserRepository: () => ({}), +})); +jest.mock('../user/repositories/organization-membership-repository-factory', () => ({ + createOrganizationMembershipRepository: () => ({}), +})); +jest.mock('../modules/repositories/authorization-session-repository-factory', () => ({ + createAuthorizationSessionRepository: () => ({}), +})); + +const { IntegrationBase } = require('../integrations/integration-base'); +const acme = { + moduleName: 'acme', + modelName: 'acme', + getName: () => 'acme', + API: { requesterType: 'apiKey' }, + requiredAuthMethods: {}, +}; +class SyncIntegration extends IntegrationBase { + static Definition = { + name: 'sync', + version: '1.0.0', + modules: { acme: { definition: acme } }, + display: { label: 'Sync', description: 'Sync', detailsUrl: 'https://example.com', icon: 'sync' }, + }; + static getOptionDetails() { + return { name: 'sync' }; + } + async initialize() {} + async send() { + return null; + } +} + +const { getEntityTypeRegistry } = require('../modules/entity-type-registry'); +const { resolveOwnershipConfig } = require('../modules/entity-scope'); +const rawDefinition = { + integrations: [SyncIntegration], + entities: { acme: { definition: acme, scope: 'organization' } }, + user: { primary: 'individual' }, +}; +const mockDefinition = (() => { + const entityTypeRegistry = getEntityTypeRegistry(rawDefinition); + return { + ...rawDefinition, + userConfig: {}, + moduleDefinitions: [acme], + entityTypeRegistry, + ownership: resolveOwnershipConfig(rawDefinition, entityTypeRegistry), + }; +})(); +jest.mock('../handlers/app-definition-loader', () => ({ + loadAppDefinition: () => mockDefinition, +})); + +const { createApp } = require('../handlers/app-handler-helpers'); +const { request } = require('./test-support/http-client'); +const { AuthenticateUser } = require('../user/use-cases/authenticate-user'); +const { User } = require('../user/user'); +const { resolveManagementApiConfig } = require('./management-api-config'); +const { createManagementApiV2Router } = require('./v2/create-v2-router'); +const { createIntegrationRouter } = require('../integrations/integration-router'); + +const ORG = '17'; +const MEMBER = '7'; +const AUTH = { authorization: 'Bearer token' }; + +function app() { + const config = resolveManagementApiConfig(mockDefinition.managementApi); + return createApp((a) => { + a.use(createManagementApiV2Router({ appDefinition: mockDefinition, config })); + a.use(createIntegrationRouter()); + }); +} + +// The organization's connection, as a repository returns it: the decrypted +// credential is attached, flat and under `data`. +const orgEntity = () => ({ + id: 'e-org', + userId: ORG, + organizationId: ORG, + moduleName: 'acme', + name: 'Org connection', + externalId: 'acct-org', + credential: { + id: 'c-org', + userId: ORG, + authIsValid: true, + ...SECRETS, + data: { ...SECRETS }, + }, +}); + +function expectNoCredentialMaterial(res, label) { + const text = JSON.stringify(res.body); + for (const value of Object.values(SECRETS)) { + expect({ label, leaked: text.includes(value) }).toEqual({ label, leaked: false }); + } + const walk = (node, path) => { + if (Array.isArray(node)) return node.forEach((v, i) => walk(v, `${path}[${i}]`)); + if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + expect({ label, path: `${path}.${key}`, credentialKey: CREDENTIAL_KEYS.includes(key) || key === 'credential' && value && typeof value === 'object' }) + .toEqual({ label, path: `${path}.${key}`, credentialKey: false }); + walk(value, `${path}.${key}`); + } + } + }; + walk(res.body, label); +} + +describe('no credential material in entity or integration responses (v1 and v2)', () => { + const previousDbType = process.env.DB_TYPE; + beforeAll(() => { + process.env.DB_TYPE = 'postgresql'; + }); + afterAll(() => { + if (previousDbType === undefined) delete process.env.DB_TYPE; + else process.env.DB_TYPE = previousDbType; + }); + beforeEach(() => { + mockState.entities = [orgEntity()]; + mockState.integrations = [ + { + id: 'i-org', + userId: ORG, + organizationId: ORG, + entitiesIds: ['e-org'], + entities: [orgEntity()], + config: { type: 'sync' }, + status: 'ENABLED', + version: '1.0.0', + messages: {}, + }, + ]; + const member = new User({ id: MEMBER, organizationId: null }, { id: ORG }, false, 'individual', true, false); + jest.spyOn(AuthenticateUser.prototype, 'execute').mockImplementation(async () => member); + for (const m of ['log', 'info', 'warn', 'debug']) { + jest.spyOn(console, m).mockImplementation(() => {}); + } + }); + afterEach(() => jest.restoreAllMocks()); + + const endpoints = [ + ['v1 GET /api/integrations', '/api/integrations'], + ['v1 GET /api/integrations/:id', '/api/integrations/i-org'], + ['v1 GET /api/entities/:id', '/api/entities/e-org'], + ['v2 GET /api/v2/entities', '/api/v2/entities'], + ['v2 GET /api/v2/entities/:id', '/api/v2/entities/e-org'], + ['v2 GET /api/v2/integrations', '/api/v2/integrations'], + ['v2 GET /api/v2/integrations/:id', '/api/v2/integrations/i-org'], + ]; + + it.each(endpoints)('%s returns the org connection without its credential', async (label, path) => { + const res = await request(app(), { method: 'GET', path, headers: AUTH }); + expect({ label, status: res.status }).toEqual({ label, status: 200 }); + // The member does see the connection, by id: the test would pass + // vacuously on a 404 or an empty list. + expect(JSON.stringify(res.body)).toContain('e-org'); + expectNoCredentialMaterial(res, label); + }); + + it('reduces the credential to its id and whether it still works', async () => { + const res = await request(app(), { method: 'GET', path: '/api/entities/e-org', headers: AUTH }); + expect(res.body).toMatchObject({ id: 'e-org', credentialId: 'c-org', authIsValid: true }); + const list = await request(app(), { method: 'GET', path: '/api/integrations', headers: AUTH }); + expect(list.body.entities.authorized[0].entity).toMatchObject({ + id: 'e-org', + credentialId: 'c-org', + authIsValid: true, + }); + }); +}); diff --git a/packages/core/management-api/v2/dto.js b/packages/core/management-api/v2/dto.js index f2b2c9a35..ef7be6dae 100644 --- a/packages/core/management-api/v2/dto.js +++ b/packages/core/management-api/v2/dto.js @@ -4,6 +4,8 @@ * does (see maskCredential). */ +const { toPublicEntities } = require('../../modules/utils/public-entity'); + const idOf = (value) => { if (value === undefined || value === null) return null; if (typeof value === 'object') { @@ -52,7 +54,8 @@ function toEntityDto(entity) { function toIntegrationDto(integration) { return { id: String(integration.id), - entities: integration.entities ?? [], + // Entity records carry the decrypted credential; never send them as is. + entities: toPublicEntities(integration.entities), status: integration.status ?? null, config: integration.config ?? {}, version: integration.version ?? null, diff --git a/packages/core/management-api/v2/dto.test.js b/packages/core/management-api/v2/dto.test.js index e8bf09055..f4730266b 100644 --- a/packages/core/management-api/v2/dto.test.js +++ b/packages/core/management-api/v2/dto.test.js @@ -1,4 +1,4 @@ -const { maskCredential, maskValue, toEntityDto } = require('./dto'); +const { maskCredential, maskValue, toEntityDto, toIntegrationDto } = require('./dto'); describe('v2 DTOs', () => { it('masks every provider field of a credential', () => { @@ -65,3 +65,22 @@ describe('v2 DTOs', () => { }); }); }); + +describe('toIntegrationDto', () => { + it('never carries an entity record’s credential, whatever the use case handed it', () => { + const dto = toIntegrationDto({ + id: 'i1', + entities: [ + 'e0', + { + id: 'e1', + moduleName: 'acme', + credential: { id: 'c1', authIsValid: true, access_token: 'AT-1', data: { refresh_token: 'RT-1' } }, + }, + ], + }); + expect(dto.entities[0]).toBe('e0'); + expect(dto.entities[1]).toMatchObject({ id: 'e1', credentialId: 'c1', authIsValid: true }); + expect(JSON.stringify(dto)).not.toMatch(/AT-1|RT-1|access_token|refresh_token/); + }); +}); diff --git a/packages/core/modules/use-cases/get-entity-options-by-id.js b/packages/core/modules/use-cases/get-entity-options-by-id.js index e5de1a45e..6049dad55 100644 --- a/packages/core/modules/use-cases/get-entity-options-by-id.js +++ b/packages/core/modules/use-cases/get-entity-options-by-id.js @@ -1,5 +1,8 @@ const { Module } = require('../module'); const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); +const { + findModuleDefinitionForEntity, +} = require('../utils/find-module-definition'); class GetEntityOptionsById { /** @@ -48,11 +51,10 @@ class GetEntityOptionsById { } const entityType = entity.moduleName; - const moduleDefinition = this.moduleDefinitions.find((def) => { - const modelName = - Module.getEntityModelFromDefinition(def).modelName; - return entityType === modelName; - }); + const moduleDefinition = findModuleDefinitionForEntity( + this.moduleDefinitions, + entity + ); if (!moduleDefinition) { throw new Error( diff --git a/packages/core/modules/use-cases/get-module.js b/packages/core/modules/use-cases/get-module.js index 936f6d558..8a5e4bc4c 100644 --- a/packages/core/modules/use-cases/get-module.js +++ b/packages/core/modules/use-cases/get-module.js @@ -1,5 +1,9 @@ const { Module } = require('../module'); const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); +const { + findModuleDefinitionForEntity, +} = require('../utils/find-module-definition'); +const { toPublicEntity } = require('../utils/public-entity'); class GetModule { constructor({ moduleRepository, moduleDefinitions, scopePolicy }) { @@ -45,10 +49,10 @@ class GetModule { } const entityType = entity.moduleName; - const moduleDefinition = this.moduleDefinitions.find((def) => { - const modelName = Module.getEntityModelFromDefinition(def).modelName; - return entityType === modelName; - }); + const moduleDefinition = findModuleDefinitionForEntity( + this.moduleDefinitions, + entity + ); if (!moduleDefinition) { throw new Error( @@ -65,15 +69,21 @@ class GetModule { definition: moduleDefinition, }); - // todo: this properties should be methods in the Module class + // The credential (tokens, API keys) never leaves the server: the + // response names it and says whether it still works. + const { credentialId, authIsValid } = toPublicEntity({ + ...module.entity, + credential: module.credential, + }); return { id: module.entity.id, name: module.entity.name, moduleName: module.entity.moduleName, - credential: module.credential, + credentialId, + authIsValid, externalId: module.entity.externalId, - userId: module.entity.user.toString(), - } + userId: toPublicEntity(module.entity).userId, + }; } } diff --git a/packages/core/modules/use-cases/refresh-entity-options.js b/packages/core/modules/use-cases/refresh-entity-options.js index ef82d2a84..fc82ed365 100644 --- a/packages/core/modules/use-cases/refresh-entity-options.js +++ b/packages/core/modules/use-cases/refresh-entity-options.js @@ -1,5 +1,8 @@ const { Module } = require('../module'); const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); +const { + findModuleDefinitionForEntity, +} = require('../utils/find-module-definition'); class RefreshEntityOptions { /** @@ -49,11 +52,10 @@ class RefreshEntityOptions { } const entityType = entity.moduleName; - const moduleDefinition = this.moduleDefinitions.find((def) => { - const modelName = - Module.getEntityModelFromDefinition(def).modelName; - return entityType === modelName; - }); + const moduleDefinition = findModuleDefinitionForEntity( + this.moduleDefinitions, + entity + ); if (!moduleDefinition) { throw new Error( diff --git a/packages/core/modules/use-cases/test-module-auth.js b/packages/core/modules/use-cases/test-module-auth.js index e9d7af995..885a42d45 100644 --- a/packages/core/modules/use-cases/test-module-auth.js +++ b/packages/core/modules/use-cases/test-module-auth.js @@ -1,5 +1,8 @@ const { Module } = require('../module'); const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); +const { + findModuleDefinitionForEntity, +} = require('../utils/find-module-definition'); class TestModuleAuth { /** @@ -48,11 +51,10 @@ class TestModuleAuth { } const entityType = entity.moduleName; - const moduleDefinition = this.moduleDefinitions.find((def) => { - const modelName = - Module.getEntityModelFromDefinition(def).modelName; - return entityType === modelName; - }); + const moduleDefinition = findModuleDefinitionForEntity( + this.moduleDefinitions, + entity + ); if (!moduleDefinition) { throw new Error( diff --git a/packages/core/modules/utils/find-module-definition.js b/packages/core/modules/utils/find-module-definition.js new file mode 100644 index 000000000..b817f5d27 --- /dev/null +++ b/packages/core/modules/utils/find-module-definition.js @@ -0,0 +1,23 @@ +/** + * The API module definition a stored entity belongs to. + * + * Entities are persisted with `moduleName` set to the definition's + * `moduleName` (see ProcessAuthorizationCallback.findOrCreateEntity). A + * definition's legacy `modelName` is accepted as well, for entities written + * before the two were the same. + * + * @param {Array} moduleDefinitions + * @param {Object} entity - entity record with a `moduleName` + * @returns {Object|undefined} + */ +function findModuleDefinitionForEntity(moduleDefinitions, entity) { + const entityType = entity?.moduleName; + if (!entityType) return undefined; + return (moduleDefinitions || []).find( + (def) => + def && + (def.moduleName === entityType || def.modelName === entityType) + ); +} + +module.exports = { findModuleDefinitionForEntity }; diff --git a/packages/core/modules/utils/map-module-dto.js b/packages/core/modules/utils/map-module-dto.js index 32b794ce5..a6a19981d 100644 --- a/packages/core/modules/utils/map-module-dto.js +++ b/packages/core/modules/utils/map-module-dto.js @@ -2,6 +2,8 @@ * @param {import('../module').Module} moduleInstance * Convert a Module domain instance to a plain DTO suitable for JSON responses. */ +const { toPublicEntity } = require('./public-entity'); + function mapModuleClassToModuleDTO(moduleInstance) { if (!moduleInstance) return null; @@ -9,7 +11,11 @@ function mapModuleClassToModuleDTO(moduleInstance) { id: moduleInstance.entity.id, name: moduleInstance.name, userId: moduleInstance.userId, - entity: moduleInstance.entity, + // Never the entity record itself: it carries the decrypted credential. + entity: toPublicEntity({ + ...moduleInstance.entity, + credential: moduleInstance.credential ?? moduleInstance.entity?.credential, + }), credentialId: moduleInstance.credential?._id?.toString(), type: moduleInstance.getName() }; diff --git a/packages/core/modules/utils/public-entity.js b/packages/core/modules/utils/public-entity.js new file mode 100644 index 000000000..61a3c6348 --- /dev/null +++ b/packages/core/modules/utils/public-entity.js @@ -0,0 +1,59 @@ +/** + * The one shape an entity (connected account) takes in any API response, + * v1 or v2, for every caller including its owner. + * + * Repositories attach the decrypted credential to the entities they return, + * so an entity record must never be sent as it is: an organization member + * listing entities or integrations would receive the organization's + * access_token, refresh_token or api_key. This keeps a fixed set of + * descriptive fields and reduces the credential to its id and whether it + * still works. + */ + +const idOf = (value) => { + if (value === undefined || value === null) return null; + if (typeof value === 'object') { + const id = value.id ?? value._id; + return id === undefined || id === null ? null : String(id); + } + return String(value); +}; + +/** + * @param {Object|string|number|null|undefined} entity - an entity record, or + * an entity id (returned as is: integrations may carry bare ids) + * @returns {Object|string|number|null} + */ +function toPublicEntity(entity) { + if (entity === undefined || entity === null) return null; + if (typeof entity !== 'object') return entity; + const credential = + entity.credential && typeof entity.credential === 'object' + ? entity.credential + : null; + const result = { + id: idOf(entity.id ?? entity._id), + name: entity.name ?? null, + moduleName: entity.moduleName ?? null, + externalId: entity.externalId ?? null, + userId: idOf(entity.userId ?? entity.user), + credentialId: idOf(entity.credential ?? entity.credentialId), + authIsValid: + typeof credential?.authIsValid === 'boolean' + ? credential.authIsValid + : typeof entity.authIsValid === 'boolean' + ? entity.authIsValid + : null, + }; + if (entity.organizationId !== undefined) { + result.organizationId = idOf(entity.organizationId); + } + return result; +} + +/** `toPublicEntity` over a list, tolerating a missing one. */ +function toPublicEntities(entities) { + return Array.isArray(entities) ? entities.map(toPublicEntity) : []; +} + +module.exports = { toPublicEntity, toPublicEntities }; diff --git a/packages/core/modules/utils/public-entity.test.js b/packages/core/modules/utils/public-entity.test.js new file mode 100644 index 000000000..7b5b951f9 --- /dev/null +++ b/packages/core/modules/utils/public-entity.test.js @@ -0,0 +1,52 @@ +const { toPublicEntity, toPublicEntities } = require('./public-entity'); + +const withCredential = { + id: 'e1', + userId: 'u1', + organizationId: 'o1', + moduleName: 'acme', + name: 'Acme', + externalId: 'x1', + credential: { + id: 'c1', + authIsValid: false, + access_token: 'AT', + refresh_token: 'RT', + api_key: 'KEY', + data: { access_token: 'AT' }, + }, + someRepositoryField: 'not public', +}; + +describe('toPublicEntity', () => { + it('keeps the credential id and authIsValid, never the credential', () => { + expect(toPublicEntity(withCredential)).toEqual({ + id: 'e1', + userId: 'u1', + organizationId: 'o1', + moduleName: 'acme', + name: 'Acme', + externalId: 'x1', + credentialId: 'c1', + authIsValid: false, + }); + }); + + it('accepts a bare credential id, the legacy user field and _id', () => { + expect(toPublicEntity({ _id: 'e2', user: 'u2', credential: 'c2' })).toMatchObject({ + id: 'e2', + userId: 'u2', + credentialId: 'c2', + authIsValid: null, + }); + expect(toPublicEntity({ id: 'e3', credentialId: 'c3' }).credentialId).toBe('c3'); + }); + + it('passes entity ids through and drops nothing from a list', () => { + expect(toPublicEntity('e4')).toBe('e4'); + expect(toPublicEntity(null)).toBeNull(); + expect(toPublicEntities(['e5', withCredential])).toEqual(['e5', expect.objectContaining({ id: 'e1' })]); + expect(toPublicEntities(undefined)).toEqual([]); + expect(JSON.stringify(toPublicEntities([withCredential]))).not.toMatch(/AT|RT|KEY/); + }); +});