fix(plugin-multi-tenant): ensure relationship filter filters based on doc.tenant (#13925)

Previously, relationship fields were only filtered based on the
`payload-tenant` cookie - if the relationship points to a relation where
`doc.relation.tenant !== cookies.get('payload-tenant')`, it will fail
validation. This is good!

However, if no headers are present (e.g. when using the local API to
create or update a document), this validation will pass, even if the
document belongs to a different tenant. The following test is passing in
this PR and failing in main: `ensure relationship document with
relationship to different tenant cannot be created even if no tenant
header passed`.

This PR extends the validation logic to respect the tenant stored in the
document's data and only read the headers if the document does not have
a tenant set yet.

Old logic:

`doc.relation.tenant !== cookies.get('payload-tenant')` => fail
validation

New logic:

`doc.relation.tenant !== doc.tenant ?? cookies.get('payload-tenant')` =>
fail validation


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211456244666493
This commit is contained in:
Alessio Gravili
2025-09-25 09:36:21 -07:00
committed by GitHub
parent f39c7bffc9
commit cbbf98e873
7 changed files with 203 additions and 26 deletions

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
export const Relationships: CollectionConfig = {
slug: 'relationships',
admin: {
useAsTitle: 'title',
group: 'Tenant Collections',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'relationship',
type: 'relationship',
relationTo: 'relationships',
},
],
}

View File

@@ -10,13 +10,14 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { AutosaveGlobal } from './collections/AutosaveGlobal.js'
import { Menu } from './collections/Menu.js'
import { MenuItems } from './collections/MenuItems.js'
import { Relationships } from './collections/Relationships.js'
import { Tenants } from './collections/Tenants.js'
import { Users } from './collections/Users/index.js'
import { seed } from './seed/index.js'
import { autosaveGlobalSlug, menuItemsSlug, menuSlug } from './shared.js'
export default buildConfigWithDefaults({
collections: [Tenants, Users, MenuItems, Menu, AutosaveGlobal],
collections: [Tenants, Users, MenuItems, Menu, AutosaveGlobal, Relationships],
admin: {
autoLogin: false,
importMap: {
@@ -48,6 +49,8 @@ export default buildConfigWithDefaults({
[autosaveGlobalSlug]: {
isGlobal: true,
},
['relationships']: {},
},
i18n: {
translations: {

View File

@@ -1,9 +1,10 @@
import type { DefaultDocumentIDType, PaginatedDocs, Payload } from 'payload'
import path from 'path'
import { NotFound, type Payload } from 'payload'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Relationship } from './payload-types.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
@@ -48,5 +49,89 @@ describe('@payloadcms/plugin-multi-tenant', () => {
expect(tenant1).toHaveProperty('id')
})
describe('relationships', () => {
let anchorBarRelationships: PaginatedDocs<Relationship>
let blueDogRelationships: PaginatedDocs<Relationship>
let anchorBarTenantID: DefaultDocumentIDType
let blueDogTenantID: DefaultDocumentIDType
beforeEach(async () => {
anchorBarRelationships = await payload.find({
collection: 'relationships',
where: {
'tenant.name': {
equals: 'Anchor Bar',
},
},
})
blueDogRelationships = await payload.find({
collection: 'relationships',
where: {
'tenant.name': {
equals: 'Blue Dog',
},
},
})
// @ts-expect-error unsafe access okay in test
anchorBarTenantID = anchorBarRelationships.docs[0].tenant.id
// @ts-expect-error unsafe access okay in test
blueDogTenantID = blueDogRelationships.docs[0].tenant.id
})
it('ensure relationship document with relationship within same tenant can be created', async () => {
const newRelationship = await payload.create({
collection: 'relationships',
data: {
title: 'Relationship to Anchor Bar',
// @ts-expect-error unsafe access okay in test
relationship: anchorBarRelationships.docs[0].id,
tenant: anchorBarTenantID,
},
req: {
headers: new Headers([['cookie', `payload-tenant=${anchorBarTenantID}`]]),
},
})
// @ts-expect-error unsafe access okay in test
expect(newRelationship.relationship?.title).toBe('Owned by bar with no ac')
})
it('ensure relationship document with relationship to different tenant cannot be created if tenant header passed', async () => {
await expect(
payload.create({
collection: 'relationships',
data: {
title: 'Relationship to Blue Dog',
// @ts-expect-error unsafe access okay in test
relationship: blueDogRelationships.docs[0].id,
tenant: anchorBarTenantID,
},
req: {
headers: new Headers([['cookie', `payload-tenant=${anchorBarTenantID}`]]),
},
}),
).rejects.toThrow('The following field is invalid: Relationship')
})
it('ensure relationship document with relationship to different tenant cannot be created even if no tenant header passed', async () => {
// Should filter based on data.tenant instead of tenant cookie
await expect(
payload.create({
collection: 'relationships',
data: {
title: 'Relationship to Blue Dog',
// @ts-expect-error unsafe access okay in test
relationship: blueDogRelationships.docs[0].id,
tenant: anchorBarTenantID,
},
req: {},
}),
).rejects.toThrow('The following field is invalid: Relationship')
})
})
})
})

View File

@@ -72,6 +72,7 @@ export interface Config {
'food-items': FoodItem;
'food-menu': FoodMenu;
'autosave-global': AutosaveGlobal;
relationships: Relationship;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -87,12 +88,13 @@ export interface Config {
'food-items': FoodItemsSelect<false> | FoodItemsSelect<true>;
'food-menu': FoodMenuSelect<false> | FoodMenuSelect<true>;
'autosave-global': AutosaveGlobalSelect<false> | AutosaveGlobalSelect<true>;
relationships: RelationshipsSelect<false> | RelationshipsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {};
globalsSelect: {};
@@ -128,11 +130,11 @@ export interface UserAuthOperations {
* via the `definition` "tenants".
*/
export interface Tenant {
id: string;
id: number;
name: string;
domain: string;
users?: {
docs?: (string | User)[];
docs?: (number | User)[];
hasNextPage?: boolean;
totalDocs?: number;
};
@@ -145,11 +147,11 @@ export interface Tenant {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
roles?: ('admin' | 'user')[] | null;
tenants?:
| {
tenant: string | Tenant;
tenant: number | Tenant;
id?: string | null;
}[]
| null;
@@ -176,8 +178,8 @@ export interface User {
* via the `definition` "food-items".
*/
export interface FoodItem {
id: string;
tenant?: (string | null) | Tenant;
id: number;
tenant?: (number | null) | Tenant;
name: string;
content?: {
root: {
@@ -202,8 +204,8 @@ export interface FoodItem {
* via the `definition` "food-menu".
*/
export interface FoodMenu {
id: string;
tenant?: (string | null) | Tenant;
id: number;
tenant?: (number | null) | Tenant;
title: string;
description?: string | null;
menuItems?:
@@ -211,7 +213,7 @@ export interface FoodMenu {
/**
* Automatically filtered by selected tenant
*/
menuItem: string | FoodItem;
menuItem: number | FoodItem;
active?: boolean | null;
id?: string | null;
}[]
@@ -224,45 +226,61 @@ export interface FoodMenu {
* via the `definition` "autosave-global".
*/
export interface AutosaveGlobal {
id: string;
tenant?: (string | null) | Tenant;
id: number;
tenant?: (number | null) | Tenant;
title: string;
description?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relationships".
*/
export interface Relationship {
id: number;
tenant?: (number | null) | Tenant;
title: string;
relationship?: (number | null) | Relationship;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'tenants';
value: string | Tenant;
value: number | Tenant;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null)
| ({
relationTo: 'food-items';
value: string | FoodItem;
value: number | FoodItem;
} | null)
| ({
relationTo: 'food-menu';
value: string | FoodMenu;
value: number | FoodMenu;
} | null)
| ({
relationTo: 'autosave-global';
value: string | AutosaveGlobal;
value: number | AutosaveGlobal;
} | null)
| ({
relationTo: 'relationships';
value: number | Relationship;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -272,10 +290,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -295,7 +313,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -383,6 +401,17 @@ export interface AutosaveGlobalSelect<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relationships_select".
*/
export interface RelationshipsSelect<T extends boolean = true> {
tenant?: T;
title?: T;
relationship?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -79,6 +79,38 @@ export const seed: Config['onInit'] = async (payload) => {
},
})
await payload.create({
collection: 'relationships',
data: {
title: 'Owned by blue dog',
tenant: blueDogTenant.id,
},
})
await payload.create({
collection: 'relationships',
data: {
title: 'Owned by steelcat',
tenant: steelCatTenant.id,
},
})
await payload.create({
collection: 'relationships',
data: {
title: 'Owned by bar with no ac',
tenant: anchorBarTenant.id,
},
})
await payload.create({
collection: 'relationships',
data: {
title: 'Owned by public tenant',
tenant: publicTenant.id,
},
})
// Create steel cat menu items
await payload.create({
collection: menuItemsSlug,