Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 21 additions & 3 deletions packages/owletto-backend/src/tools/admin/manage_entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof createEntity>[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<typeof createEntity>[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<typeof createEntity>[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<typeof createEntity>[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);
});
});
18 changes: 13 additions & 5 deletions packages/owletto-backend/src/utils/entity-link-upsert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,20 @@ async function createEntityWithIdentities(params: {
const metadata: Record<string, unknown> = {};
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) {
Expand Down
29 changes: 24 additions & 5 deletions packages/owletto-backend/src/utils/entity-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Comment on lines +255 to +258
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce schema checks for public-catalog entity types

This change allows createEntity to resolve entity_type from any public org, but metadata validation is still scoped to ctx.organizationId in validateEntityMetadata (utils/schema-validation.ts), which returns "valid" when it cannot find a schema. In practice, creating or updating a tenant entity that uses a public catalog type can now bypass that type's JSON schema entirely, so required fields and constraints from the catalog are silently skipped and invalid metadata is persisted.

Useful? React with 👍 / 👎.

ORDER BY (et.organization_id = ${data.organization_id}) DESC, et.id ASC
LIMIT 1
`;
if (typeRow.length === 0) {
Expand Down
33 changes: 26 additions & 7 deletions packages/owletto-backend/src/utils/relationship-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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.`
);
}
}

Expand Down
Loading