diff --git a/packages/owletto-backend/src/__tests__/integration/relationships/entity-relationships.test.ts b/packages/owletto-backend/src/__tests__/integration/relationships/entity-relationships.test.ts index 72e0179a1..efdf5424c 100644 --- a/packages/owletto-backend/src/__tests__/integration/relationships/entity-relationships.test.ts +++ b/packages/owletto-backend/src/__tests__/integration/relationships/entity-relationships.test.ts @@ -580,6 +580,51 @@ describe('Entity Relationships', () => { ).rejects.toThrow(/organization/i); }); + it('should allow cross-org relationship when target is in a public-catalog org', async () => { + const publicOrg = await createTestOrganization({ + name: 'Public Catalog Org', + visibility: 'public', + }); + const publicEntity = await createTestEntity({ + name: 'Public Canonical Entity', + entity_type: 'brand', + organization_id: publicOrg.id, + }); + + const result = await mcpToolsCall( + 'manage_entity', + { + action: 'link', + from_entity_id: entityA1.id, + to_entity_id: publicEntity.id, + relationship_type_slug: 'depends-on', + }, + { token: tokenA } + ); + expect(result.action).toBe('link'); + // Relationship's organization_id is the source's (caller's) org, not the target's. + expect(result.relationship.organization_id).toBe(orgA.id); + }); + + it('should reject a relationship whose source is in a different org from the caller', async () => { + // userA is signed in (tokenA → orgA), but the source entity is in orgB. + // Even though tokenA's caller has access to read entityB1, they cannot + // author a relationship *from* it — sources must always be in the + // caller's org. + await expect( + mcpToolsCall( + 'manage_entity', + { + action: 'link', + from_entity_id: entityB1.id, + to_entity_id: entityA2.id, + relationship_type_slug: 'depends-on', + }, + { token: tokenA } + ) + ).rejects.toThrow(/does not belong to your organization/i); + }); + it('should reject nonexistent relationship type', async () => { await expect( mcpToolsCall( diff --git a/packages/owletto-backend/src/tools/admin/manage_entity.ts b/packages/owletto-backend/src/tools/admin/manage_entity.ts index d3e3b76fe..6d5c4c4c9 100644 --- a/packages/owletto-backend/src/tools/admin/manage_entity.ts +++ b/packages/owletto-backend/src/tools/admin/manage_entity.ts @@ -933,9 +933,27 @@ async function handleLink( let fromId = args.from_entity_id; let toId = args.to_entity_id; if (isSymmetric) { - const canonical = canonicalizeSymmetricEdge(fromId, toId); - fromId = canonical.from; - toId = canonical.to; + // For symmetric same-org pairs we canonicalize by id so dedup catches + // a → b and b → a as the same edge. For cross-org pairs (target in a + // public catalog), keep the caller's-org entity as `from` even if its + // id is higher, so the stored source matches the semantic source. The + // canonical form would otherwise leave rows where `from_entity_id` + // points at a public catalog row under a tenant `organization_id` — + // tenant-owned but cosmetically inverted. + const orgRows = await sql<{ id: number; organization_id: string }>` + SELECT id, organization_id FROM entities WHERE id IN (${fromId}, ${toId}) + `; + const orgOf = (id: number) => + String(orgRows.find((r) => Number(r.id) === id)?.organization_id); + const sameOrg = + orgOf(fromId) === ctx.organizationId && orgOf(toId) === ctx.organizationId; + if (sameOrg) { + const canonical = canonicalizeSymmetricEdge(fromId, toId); + fromId = canonical.from; + toId = canonical.to; + } + // else: cross-org symmetric — preserve caller-from / public-to. + // validateScopeRule already required `from` to be in caller's org. } await checkDuplicateEdge(fromId, toId, typeId, sql); diff --git a/packages/owletto-backend/src/utils/__tests__/entity-management-schema-search.test.ts b/packages/owletto-backend/src/utils/__tests__/entity-management-schema-search.test.ts new file mode 100644 index 000000000..4c2c95bce --- /dev/null +++ b/packages/owletto-backend/src/utils/__tests__/entity-management-schema-search.test.ts @@ -0,0 +1,177 @@ +/** + * Schema search path: createEntity falls back to public-catalog orgs when a + * type slug isn't registered in the entity's own org. Tenant-local types + * still win when both exist (so user-defined types beat catalog types of the + * same slug). + */ + +import type { EntityLinkRule } from '@lobu/owletto-sdk'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase, getTestDb } from '../../__tests__/setup/test-db'; +import { + addUserToOrganization, + createTestConnectorDefinition, + createTestOrganization, + createTestUser, +} from '../../__tests__/setup/test-fixtures'; +import { applyEntityLinks, clearEntityLinkRulesCache } from '../entity-link-upsert'; +import { createEntity } from '../entity-management'; + +async function seedEntityType(orgId: string, slug: string) { + const sql = getTestDb(); + const rows = await sql<{ id: number }[]>` + INSERT INTO entity_types (organization_id, slug, name, created_at, updated_at) + VALUES (${orgId}, ${slug}, ${slug}, current_timestamp, current_timestamp) + RETURNING id + `; + return rows[0].id; +} + +describe('entity-management schema search path', () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + it('resolves an entity_type from a public-catalog org when missing locally', async () => { + const tenant = await createTestOrganization({ name: 'Tenant Schema Search' }); + const publicCatalog = await createTestOrganization({ + name: 'Public Catalog A', + visibility: 'public', + }); + const user = await createTestUser(); + await addUserToOrganization(user.id, tenant.id, 'owner'); + + const publicTypeId = await seedEntityType(publicCatalog.id, 'tax_filing'); + + const created = await createEntity({ + entity_type: 'tax_filing', + name: 'Self Assessment 2024-25', + organization_id: tenant.id, + created_by: user.id, + } as Parameters[0]); + + expect(created.entity_type).toBe('tax_filing'); + + const sql = getTestDb(); + const rows = await sql<{ entity_type_id: number; organization_id: string }[]>` + SELECT entity_type_id, organization_id FROM entities WHERE id = ${created.id} + `; + // Materialized: the entity row lives in tenant, but its type points at the + // public-catalog row. + expect(String(rows[0].organization_id)).toBe(tenant.id); + expect(Number(rows[0].entity_type_id)).toBe(publicTypeId); + }); + + it('prefers a tenant-local entity_type over a public-catalog one with the same slug', async () => { + const tenant = await createTestOrganization({ name: 'Tenant Local-Wins' }); + const publicCatalog = await createTestOrganization({ + name: 'Public Catalog B', + visibility: 'public', + }); + const user = await createTestUser(); + await addUserToOrganization(user.id, tenant.id, 'owner'); + + await seedEntityType(publicCatalog.id, 'tax_filing'); + const tenantTypeId = await seedEntityType(tenant.id, 'tax_filing'); + + const created = await createEntity({ + entity_type: 'tax_filing', + name: 'Local Override', + organization_id: tenant.id, + created_by: user.id, + } as Parameters[0]); + + const sql = getTestDb(); + const rows = await sql<{ entity_type_id: number }[]>` + SELECT entity_type_id FROM entities WHERE id = ${created.id} + `; + expect(Number(rows[0].entity_type_id)).toBe(tenantTypeId); + }); + + it('rejects an unknown entity_type that isn\'t in tenant or any public catalog', async () => { + const tenant = await createTestOrganization({ name: 'Tenant Unknown-Type' }); + const user = await createTestUser(); + await addUserToOrganization(user.id, tenant.id, 'owner'); + + await expect( + createEntity({ + entity_type: 'never_registered_anywhere', + name: 'Should Fail', + organization_id: tenant.id, + created_by: user.id, + } as Parameters[0]) + ).rejects.toThrow(/Unknown entity type/i); + }); + + it('does not search private orgs that the caller is not a member of', async () => { + const tenant = await createTestOrganization({ name: 'Tenant No-Snoop' }); + const otherPrivate = await createTestOrganization({ + name: 'Some Other Private Org', + visibility: 'private', + }); + const user = await createTestUser(); + await addUserToOrganization(user.id, tenant.id, 'owner'); + + await seedEntityType(otherPrivate.id, 'secret_type'); + + await expect( + createEntity({ + entity_type: 'secret_type', + name: 'Should Fail', + organization_id: tenant.id, + created_by: user.id, + } as Parameters[0]) + ).rejects.toThrow(/Unknown entity type/i); + }); + + // The same resolver lives in entity-link-upsert.ts (auto-link path). Drift + // here would be caught by this test. + it('entity-link-upsert resolves a public-catalog type when no tenant type matches', async () => { + const tenant = await createTestOrganization({ name: 'Tenant Auto-Link' }); + const publicCatalog = await createTestOrganization({ + name: 'Public Catalog C', + visibility: 'public', + }); + const user = await createTestUser(); + await addUserToOrganization(user.id, tenant.id, 'owner'); + + const publicTypeId = await seedEntityType(publicCatalog.id, 'public_actor'); + + const connectorKey = 'auto-link-cross-org'; + const feedKey = 'msgs'; + const originType = 'msg'; + const rule: EntityLinkRule = { + entityType: 'public_actor', + autoCreate: true, + titlePath: 'metadata.name', + identities: [{ namespace: 'phone', eventPath: 'metadata.phone' }], + }; + await createTestConnectorDefinition({ + key: connectorKey, + name: connectorKey, + organization_id: tenant.id, + feeds_schema: { + [feedKey]: { eventKinds: { [originType]: { entityLinks: [rule] } } }, + }, + }); + clearEntityLinkRulesCache(); + + await applyEntityLinks({ + connectorKey, + feedKey, + orgId: tenant.id, + items: [ + { origin_type: originType, metadata: { phone: '14155551234', name: 'Alex' } }, + ], + }); + + const sql = getTestDb(); + const rows = await sql<{ id: number; entity_type_id: number; organization_id: string }[]>` + SELECT id, entity_type_id, organization_id + FROM entities + WHERE organization_id = ${tenant.id} AND name = 'Alex' AND deleted_at IS NULL + `; + expect(rows.length).toBe(1); + expect(Number(rows[0].entity_type_id)).toBe(publicTypeId); + }); +}); diff --git a/packages/owletto-backend/src/utils/entity-link-upsert.ts b/packages/owletto-backend/src/utils/entity-link-upsert.ts index ca86ca472..bfb426077 100644 --- a/packages/owletto-backend/src/utils/entity-link-upsert.ts +++ b/packages/owletto-backend/src/utils/entity-link-upsert.ts @@ -212,12 +212,20 @@ async function createEntityWithIdentities(params: { const metadata: Record = {}; for (const [key, value] of params.traits) metadata[key] = value; - // Resolve entity_type slug to FK on entity_types(id). + // Resolve entity_type slug → entity_types(id). Same schema search path as + // createEntity: try the entity's own org first, then any visibility='public' + // catalog. First match wins. See createEntity for the slug-poisoning caveat. const typeRow = await sql<{ id: number }>` - SELECT id FROM entity_types - WHERE slug = ${params.entityType} - AND organization_id = ${params.orgId} - AND deleted_at IS NULL + SELECT et.id + FROM entity_types et + LEFT JOIN organization o ON o.id = et.organization_id + WHERE et.slug = ${params.entityType} + AND et.deleted_at IS NULL + AND ( + et.organization_id = ${params.orgId} + OR o.visibility = 'public' + ) + ORDER BY (et.organization_id = ${params.orgId}) DESC, et.id ASC LIMIT 1 `; if (typeRow.length === 0) { diff --git a/packages/owletto-backend/src/utils/entity-management.ts b/packages/owletto-backend/src/utils/entity-management.ts index c2e06dade..d474ec706 100644 --- a/packages/owletto-backend/src/utils/entity-management.ts +++ b/packages/owletto-backend/src/utils/entity-management.ts @@ -232,12 +232,31 @@ export async function createEntity( const sql = getDb(); - // Resolve entity_type slug to FK on entity_types(id). + // Resolve entity_type slug → entity_types(id) via the schema search path: + // 1. The entity's own org (the user's tenant — local types win). + // 2. Any org with visibility='public' (canonical/world-knowledge catalogs). + // First match wins. The resolved id is materialized on the row so reads + // never need to repeat the search. `ORDER BY (et.organization_id = own_org) + // DESC` keeps tenant-local types ahead of public ones when both exist. + // + // KNOWN LIMITATION: this trusts every visibility='public' org as a curated + // catalog. If a tenant can flip their own org public *and* register types + // before another tenant references the same slug, they could squat on + // common slugs (`brand`, `tax_filing`). Operationally we restrict + // visibility flips to admins; long-term the right fix is either an + // explicit `is_catalog` flag on `organization` or per-agent `uses_catalog` + // declarations narrowing the search scope. const typeRow = await sql<{ id: number }>` - SELECT id FROM entity_types - WHERE slug = ${data.entity_type} - AND deleted_at IS NULL - AND organization_id = ${data.organization_id} + SELECT et.id + FROM entity_types et + LEFT JOIN organization o ON o.id = et.organization_id + WHERE et.slug = ${data.entity_type} + AND et.deleted_at IS NULL + AND ( + et.organization_id = ${data.organization_id} + OR o.visibility = 'public' + ) + ORDER BY (et.organization_id = ${data.organization_id}) DESC, et.id ASC LIMIT 1 `; if (typeRow.length === 0) { diff --git a/packages/owletto-backend/src/utils/relationship-validation.ts b/packages/owletto-backend/src/utils/relationship-validation.ts index ac2fbe1ba..ecb64f413 100644 --- a/packages/owletto-backend/src/utils/relationship-validation.ts +++ b/packages/owletto-backend/src/utils/relationship-validation.ts @@ -57,7 +57,17 @@ export function canonicalizeSymmetricEdge( /** * Validate scope rule for a relationship. - * Both entities must belong to the same organization. + * + * The from_entity must belong to the caller's organization (relationships are + * always authored from the source's org). The to_entity may be either in the + * same org or in a public-catalog org (`organization.visibility='public'`), + * which lets a tenant's relationship reference canonical world entities like + * HMRC or Barclays without copying them locally. + * + * Public → tenant references are forbidden — public catalogs never reach into + * private orgs. The relationship row's organization_id always matches the + * source's org (the caller's), keeping the assertion under the caller's + * delete/visibility control. */ export async function validateScopeRule( fromEntityId: number, @@ -68,9 +78,10 @@ export async function validateScopeRule( const sql = getDb(); const rows = await sql` - SELECT id, organization_id - FROM entities - WHERE id IN (${fromEntityId}, ${toEntityId}) + SELECT e.id, e.organization_id, o.visibility + FROM entities e + LEFT JOIN organization o ON o.id = e.organization_id + WHERE e.id IN (${fromEntityId}, ${toEntityId}) `; if (rows.length < 2) { @@ -82,12 +93,20 @@ export async function validateScopeRule( const fromEntity = rows.find((r) => Number(r.id) === fromEntityId)!; const toEntity = rows.find((r) => Number(r.id) === toEntityId)!; - // Multi-tenant: both entities must be in the same organization as the user + // Source must always be in the caller's org — you can't author relationships + // *from* someone else's entity. if (String(fromEntity.organization_id) !== ctx.organizationId) { throw new Error(`Entity ${fromEntityId} does not belong to your organization`); } - if (String(toEntity.organization_id) !== ctx.organizationId) { - throw new Error(`Entity ${toEntityId} does not belong to your organization`); + + // Target may be same-org OR a public-catalog entity. Anything else (a + // private org you don't control) is rejected. + const toOrgId = String(toEntity.organization_id); + const toVisibility = String(toEntity.visibility ?? 'private'); + if (toOrgId !== ctx.organizationId && toVisibility !== 'public') { + throw new Error( + `Entity ${toEntityId} is in a private organization that does not belong to you. Cross-org references are only allowed to entities in public catalogs.` + ); } }