diff --git a/docs/guides/entity-scopes.md b/docs/guides/entity-scopes.md new file mode 100644 index 000000000..d6dc77cc7 --- /dev/null +++ b/docs/guides/entity-scopes.md @@ -0,0 +1,195 @@ +# 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. + +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 +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/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..f3cb748ae 100644 --- a/docs/reference/core-concepts.md +++ b/docs/reference/core-concepts.md @@ -52,6 +52,39 @@ 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. + +`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 Frigg supports automated testing with tools like Jest to maintain reliability and performance. 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.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..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, @@ -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({ @@ -142,7 +152,7 @@ function createIntegrationCommands({ integrationClass } = {}) { moduleRepository, }); - const getIntegrationsForUserUseCase = new GetIntegrationsForUser({ + const getIntegrationsForUserUseCase = new GetIntegrationsByUserId({ integrationRepository, integrationClasses: [integrationClass], moduleFactory, 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/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 3b1eae654..01103b817 100644 --- a/packages/core/handlers/app-definition-loader.js +++ b/packages/core/handlers/app-definition-loader.js @@ -8,16 +8,16 @@ const { extractCredentialFieldsFromModules, } = require('../database/encryption/encryption-schema-registry'); const { - getModulesDefinitionFromIntegrationClasses, -} = require('../integrations/utils/map-integration-dto'); + 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. -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. } @@ -34,10 +34,11 @@ 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, 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`); @@ -59,6 +60,7 @@ function loadAppDefinition() { const { integrations = [], + entities = null, user: userConfig = null, adminScripts = [], reports = [], @@ -71,7 +73,18 @@ function loadAppDefinition() { // key source would otherwise start cleanly and answer every login with 500. assertAdopterJwtConfig(userConfig); - 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); + + // 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 // router bundle that loads the app definition at module scope (telemetry is @@ -96,6 +109,10 @@ function loadAppDefinition() { return { integrations, + 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 abc203881..e05df0320 100644 --- a/packages/core/handlers/app-definition-loader.test.js +++ b/packages/core/handlers/app-definition-loader.test.js @@ -8,6 +8,8 @@ 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'); @@ -164,4 +166,115 @@ 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); + }); + }); + + 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/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 f34848bb3..8bd762186 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 9665995cd..6c6e12196 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -107,6 +107,11 @@ const { Requester, ModuleConstants, ModuleFactory, + getEntityTypeRegistry, + EntityRegistryError, + resolveScope, + listBindingScopes, + OwnershipConfigError, } = require('./modules/index'); const application = require('./application'); const utils = require('./utils'); @@ -162,6 +167,7 @@ module.exports = { Options, checkRequiredParams, createIntegrationRouter, + // Deprecated (ADR-059): use getEntityTypeRegistry. getModulesDefinitionFromIntegrationClasses, LoadIntegrationContextUseCase, CreateProcess, @@ -218,6 +224,11 @@ module.exports = { Requester, ModuleConstants, ModuleFactory, + getEntityTypeRegistry, + EntityRegistryError, + resolveScope, + listBindingScopes, + OwnershipConfigError, // queues QueuerUtil, diff --git a/packages/core/integrations/index.js b/packages/core/integrations/index.js index c2d432741..3cd8a3e84 100644 --- a/packages/core/integrations/index.js +++ b/packages/core/integrations/index.js @@ -21,6 +21,7 @@ module.exports = { Options, createIntegrationRouter, checkRequiredParams, + // Deprecated (ADR-059): a wrapper over getEntityTypeRegistry. getModulesDefinitionFromIntegrationClasses, LoadIntegrationContextUseCase, validateExtensionBinding, diff --git a/packages/core/integrations/integration-router.js b/packages/core/integrations/integration-router.js index ba9d17329..ed5ef2e59 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'); @@ -42,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'); @@ -64,12 +65,32 @@ 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 { + CreateEntityFromCredential, +} = require('../modules/use-cases/create-entity-from-credential'); +const { + ResolveIntegrationOwnership, +} = require('./use-cases/resolve-integration-ownership'); +const { GetOwnedIntegrationRecord } = require('./use-cases/integration-operations-for-user'); 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, + 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(); @@ -92,8 +113,7 @@ function createIntegrationRouter() { const moduleFactory = new ModuleFactory({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, }); const deleteIntegrationForUser = new DeleteIntegrationForUser({ integrationRepository, @@ -106,6 +126,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({ @@ -120,8 +153,8 @@ function createIntegrationRouter() { const getEntitiesForUser = new GetEntitiesForUser({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, + scopePolicy, }); const getIntegrationInstance = new GetIntegrationInstance({ @@ -137,54 +170,65 @@ 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, + scopePolicy, }); const getModule = new GetModule({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, + scopePolicy, }); const getEntityOptionsById = new GetEntityOptionsById({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, + scopePolicy, }); const refreshEntityOptions = new RefreshEntityOptions({ moduleRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, + scopePolicy, }); const getPossibleIntegrations = new GetPossibleIntegrations({ integrationClasses, }); + // The owner of what an authorization creates follows its scope (ADR-056 §3.2). const processAuthorizationCallback = new ProcessAuthorizationCallback({ moduleRepository, credentialRepository, integrationRepository, - moduleDefinitions: - getModulesDefinitionFromIntegrationClasses(integrationClasses), + moduleDefinitions, + }); + const authorizeEntityForUser = new AuthorizeEntityForUser({ + scopePolicy, + processAuthorizationCallback, + }); + // v1 POST /api/entity: same owner resolution as /api/authorize. + const createEntityFromCredential = new CreateEntityFromCredential({ + authorizeEntityForUser, + processAuthorizationCallback, + credentialRepository, + moduleDefinitions, }); const router = express(); setIntegrationRoutes(router, authenticateUser, { + resolveIntegrationOwnership, + getOwnedIntegrationRecord, createIntegration, deleteIntegrationForUser, getIntegrationsForUser, @@ -201,7 +245,8 @@ function createIntegrationRouter() { getModule, getEntityOptionsById, refreshEntityOptions, - processAuthorizationCallback, + authorizeEntityForUser, + createEntityFromCredential, }); return router; } @@ -238,6 +283,8 @@ function checkRequiredParams(params, requiredKeys) { */ function setIntegrationRoutes(router, authenticateUser, useCases) { const { + resolveIntegrationOwnership, + getOwnedIntegrationRecord, createIntegration, deleteIntegrationForUser, getIntegrationsForUser, @@ -246,15 +293,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, }; @@ -266,7 +318,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', @@ -274,10 +325,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); @@ -287,12 +345,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); @@ -305,7 +362,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({}); }) @@ -317,7 +374,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')); }) @@ -333,7 +390,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { ]); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json( @@ -347,7 +404,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)); }) @@ -364,7 +421,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { ]); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json( @@ -389,7 +446,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) { ]); const integration = await getIntegrationInstance.execute( params.integrationId, - user.getId() + await ownerOf(params.integrationId, user) ); res.json( @@ -410,7 +467,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)); }) @@ -427,14 +484,15 @@ 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 res.json({ id: integration.id, - entities: integration.entities, + // Entity records carry the decrypted credential. + entities: toPublicEntities(integration.entities), status: integration.status, config: integration.config, }); @@ -447,7 +505,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) { @@ -485,16 +543,21 @@ function setEntityRoutes(router, authenticateUser, useCases) { getModule, getEntityOptionsById, refreshEntityOptions, - processAuthorizationCallback, + authorizeEntityForUser, + createEntityFromCredential, } = 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 } ); @@ -513,11 +576,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' @@ -529,11 +596,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', @@ -550,35 +619,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 - ); - - res.json(await module.findOrCreateEntity(entityDetails)); + // The owner follows the entity type's scope, as /api/authorize. + 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/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..a4d17058d 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,28 @@ 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 []; + // 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: + organizationId === null ? null : toObjectId(organizationId), + })), + }); + return records.map((doc) => this._mapIntegration(doc)); + } + async findIntegrations({ type, status } = {}) { const filter = {}; if (type) { @@ -68,11 +91,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 +155,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 +329,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-authorize-logs.test.js b/packages/core/integrations/tests/integration-router-authorize-logs.test.js index 1adafe8ef..3bd7936c0 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..a897f6725 --- /dev/null +++ b/packages/core/integrations/tests/integration-router-entity-registry.test.js @@ -0,0 +1,151 @@ +/** + * 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: () => ({}), +})); +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 { + 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/tests/integration-router-ownership.test.js b/packages/core/integrations/tests/integration-router-ownership.test.js new file mode 100644 index 000000000..483651600 --- /dev/null +++ b/packages/core/integrations/tests/integration-router-ownership.test.js @@ -0,0 +1,241 @@ +/** + * 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 { 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'); +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('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/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).toMatchObject({ id: 'e1', credentialId: null }); + 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(); + 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-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 887611a9a..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 @@ -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, @@ -169,7 +169,7 @@ describe('GetIntegrationsForUser Use-Case', () => { 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/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/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 603ba7249..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, @@ -19,19 +22,48 @@ function mapIntegrationClassToIntegrationDTO(integration) { }; } +let deprecationLogged = false; -const getModulesDefinitionFromIntegrationClasses = (integrationClasses) => { - return [ - ...new Set( - integrationClasses - .map((integration) => - Object.values(integration.Definition.modules).map( - (module) => module.definition - ) - ) - .flat() - ), - ]; -}; +/** + * @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 }; \ No newline at end of file +module.exports = { + mapIntegrationClassToIntegrationDTO, + getModulesDefinitionFromIntegrationClasses, +}; diff --git a/packages/core/management-api/contract.test.js b/packages/core/management-api/contract.test.js index db8abc2f9..fbef546cb 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/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/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 521a689fd..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: { @@ -138,15 +161,31 @@ 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 }, 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: { @@ -191,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 e806c3f7c..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,14 +114,21 @@ 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: { 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/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 48ac82007..c87cb6a89 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,16 @@ 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 { 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'); @@ -68,8 +85,18 @@ 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); + // 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(); @@ -92,6 +119,7 @@ function buildV2Dependencies({ appDefinition, config }) { const getEntityModuleForUser = new GetEntityModuleForUser({ moduleRepository, moduleDefinitions, + scopePolicy, }); const getCredentialForUser = new credentialOps.GetCredentialForUserV2({ credentialRepository, @@ -109,17 +137,22 @@ function buildV2Dependencies({ appDefinition, config }) { 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, @@ -129,6 +162,7 @@ function buildV2Dependencies({ appDefinition, config }) { return { config, moduleDefinitions, + scopePolicy, repositories: { moduleRepository, integrationRepository, @@ -144,6 +178,7 @@ function buildV2Dependencies({ appDefinition, config }) { integrationClasses, moduleFactory, moduleRepository, + scopePolicy, }), getPossibleIntegrations: new GetPossibleIntegrations({ integrationClasses }), createIntegrationForUser: new integrationOps.CreateIntegrationForUser({ @@ -152,7 +187,11 @@ function buildV2Dependencies({ appDefinition, config }) { integrationClasses, moduleFactory, }), - getEntityModuleForUser, + resolveIntegrationOwnership: new ResolveIntegrationOwnership({ + moduleRepository, + integrationClasses, + scopePolicy, + }), integrationClasses, }), getIntegrationInstanceForUser, @@ -184,7 +223,10 @@ function buildV2Dependencies({ appDefinition, config }) { // Entities getEntityModuleForUser, - listEntitiesForUser: new entityOps.ListEntitiesForUser({ moduleRepository }), + listEntitiesForUser: new entityOps.ListEntitiesForUser({ + moduleRepository, + scopePolicy, + }), getEntityForUser: new entityOps.GetEntityForUser({ getEntityModuleForUser }), deleteEntityForUser: new entityOps.DeleteEntityForUser({ getEntityModuleForUser, @@ -196,8 +238,8 @@ function buildV2Dependencies({ appDefinition, config }) { refreshEntityOptionsForUser: new entityOps.RefreshEntityOptionsForUser({ getEntityModuleForUser, }), - listEntityTypes: new ListEntityTypes({ moduleDefinitions }), - getEntityType: new GetEntityType({ moduleDefinitions }), + 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..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') { @@ -13,6 +15,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 +46,7 @@ function toEntityDto(entity) { typeof credential?.authIsValid === 'boolean' ? credential.authIsValid : null, + ...tenancyFields(entity.tenancy), }; } @@ -37,11 +54,13 @@ 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, messages: integration.messages ?? {}, + ...tenancyFields(integration.tenancy), }; } @@ -115,6 +134,7 @@ function maskCredential(credential) { } module.exports = { + tenancyFields, toEntityDto, toIntegrationDto, maskCredential, 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/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/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/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..957464a1e 100644 --- a/packages/core/modules/index.js +++ b/packages/core/modules/index.js @@ -4,6 +4,15 @@ 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'); +const { + resolveScope, + listBindingScopes, + OwnershipConfigError, +} = require('./entity-scope'); module.exports = { ApiKeyRequester, @@ -12,4 +21,9 @@ module.exports = { Requester, ModuleConstants, ModuleFactory, + getEntityTypeRegistry, + EntityRegistryError, + resolveScope, + listBindingScopes, + OwnershipConfigError, }; 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/__tests__/module-repository-ownership.test.js b/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js new file mode 100644 index 000000000..9c933b60e --- /dev/null +++ b/packages/core/modules/repositories/__tests__/module-repository-ownership.test.js @@ -0,0 +1,227 @@ +/** + * 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('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([]); + 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('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); + 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' }); + }); + 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 0e4571314..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, @@ -76,6 +77,29 @@ 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 []; + // 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: + 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 +139,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 +164,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); @@ -247,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 } }); @@ -329,19 +362,30 @@ 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; } _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, + 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..ba79e15dd 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,26 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { return credentialMap; } + /** + * The one place a stored entity row becomes the entity the application sees. + * 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, + organizationId: row.organizationId ?? null, + }; + } + /** * Find entity by ID with credential * Replaces: Entity.findById(entityId).populate('credential') @@ -95,15 +116,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 +134,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 +152,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 +174,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 +237,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 +257,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 +265,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 +289,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface { name, moduleName, externalId, + organizationId: tenancy.organizationId ?? null, data: dynamicData, }; @@ -296,15 +299,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 +353,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 +416,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..bc6e09fc2 100644 --- a/packages/core/modules/repositories/module-repository-postgres.js +++ b/packages/core/modules/repositories/module-repository-postgres.js @@ -108,6 +108,26 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { return credentialMap; } + /** + * The one place a stored entity row becomes the entity the application sees. + * 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, + organizationId: row.organizationId?.toString() ?? null, + }; + } + /** * Find entity by ID with credential * Replaces: Entity.findById(entityId).populate('credential') @@ -129,15 +149,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 +169,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 +189,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 +213,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 +277,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 +297,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 +305,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 +329,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface { name, moduleName, externalId, + organizationId: this._convertId(tenancy.organizationId ?? null), data: dynamicData, }; @@ -337,15 +339,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 +395,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 +460,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/__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/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/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/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 0dc118a0c..60f09ac22 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,74 @@ function findDefinition(moduleDefinitions, entityType) { return definition; } -/** Lists the entity types (API modules) this app can authorize. */ +/** + * 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) { + 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, scopePolicy }) { + this.registry = requireRegistry(entityTypeRegistry); + this.scopePolicy = scopePolicy; } - async execute() { - return this.moduleDefinitions - .map(describeEntityType) + /** @param {{user?: Object}} [params] - the caller, for the level-6 scope default */ + async execute({ user } = {}) { + return [...this.registry] + .map(([name, entry]) => describeEntry(name, entry, this.scopePolicy, user)) .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, scopePolicy }) { + this.registry = requireRegistry(entityTypeRegistry); + this.scopePolicy = scopePolicy; } - async execute(entityType) { - return describeEntityType(findDefinition(this.moduleDefinitions, 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, this.scopePolicy, user); } } 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/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..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,4 +1,8 @@ const { Module } = require('../module'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); +const { + findModuleDefinitionForEntity, +} = require('../utils/find-module-definition'); class GetEntityOptionsById { /** @@ -6,9 +10,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 +41,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) { @@ -45,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( @@ -57,8 +62,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 9a6c14917..8a5e4bc4c 100644 --- a/packages/core/modules/use-cases/get-module.js +++ b/packages/core/modules/use-cases/get-module.js @@ -1,9 +1,16 @@ 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 }) { + 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 +39,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) { @@ -42,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( @@ -53,21 +60,30 @@ 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, }); - // 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/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..fc82ed365 100644 --- a/packages/core/modules/use-cases/refresh-entity-options.js +++ b/packages/core/modules/use-cases/refresh-entity-options.js @@ -1,4 +1,8 @@ const { Module } = require('../module'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); +const { + findModuleDefinitionForEntity, +} = require('../utils/find-module-definition'); class RefreshEntityOptions { /** @@ -6,9 +10,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 +42,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) { @@ -46,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( @@ -58,8 +63,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 e11e3ce1e..885a42d45 100644 --- a/packages/core/modules/use-cases/test-module-auth.js +++ b/packages/core/modules/use-cases/test-module-auth.js @@ -1,4 +1,8 @@ const { Module } = require('../module'); +const { DEFAULT_SCOPE_POLICY } = require('../scope-policy'); +const { + findModuleDefinitionForEntity, +} = require('../utils/find-module-definition'); class TestModuleAuth { /** @@ -6,9 +10,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 +41,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) { @@ -45,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( @@ -57,8 +62,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, }); 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/); + }); +}); 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/index.d.ts b/packages/core/types/index.d.ts index 45890a1a7..bbfc379f9 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -13,3 +13,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..0151bffd7 --- /dev/null +++ b/packages/core/types/modules/index.d.ts @@ -0,0 +1,155 @@ +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; + } + + /** + * 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. + */ + export function getEntityTypeRegistry( + 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"; + } +} 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 d62d4e519..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", @@ -122,6 +144,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"], @@ -142,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 626674112..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.", @@ -685,6 +695,88 @@ }, "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 + } + }, + "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-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); + }); +}); 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); + }); +});