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:
21
test/plugin-multi-tenant/collections/Relationships.ts
Normal file
21
test/plugin-multi-tenant/collections/Relationships.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user