fix: join collection read access (#9930)
Respect read access control through the join field collections for GraphQL and admin UI fixes #9922 and #9865
This commit is contained in:
@@ -255,18 +255,17 @@ export function buildObjectType({
|
|||||||
[field.on]: { equals: parent._id ?? parent.id },
|
[field.on]: { equals: parent._id ?? parent.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
const results = await req.payload.find({
|
return await req.payload.find({
|
||||||
collection,
|
collection,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
fallbackLocale: req.fallbackLocale,
|
fallbackLocale: req.fallbackLocale,
|
||||||
limit,
|
limit,
|
||||||
locale: req.locale,
|
locale: req.locale,
|
||||||
|
overrideAccess: false,
|
||||||
req,
|
req,
|
||||||
sort,
|
sort,
|
||||||
where: fullWhere,
|
where: fullWhere,
|
||||||
})
|
})
|
||||||
|
|
||||||
return results
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export const buildTableState = async (
|
|||||||
collection: collectionSlug,
|
collection: collectionSlug,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
limit: query?.limit ? parseInt(query.limit, 10) : undefined,
|
limit: query?.limit ? parseInt(query.limit, 10) : undefined,
|
||||||
|
overrideAccess: false,
|
||||||
page: query?.page ? parseInt(query.page, 10) : undefined,
|
page: query?.page ? parseInt(query.page, 10) : undefined,
|
||||||
sort: query?.sort,
|
sort: query?.sort,
|
||||||
where: query?.where,
|
where: query?.where,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Uploads } from './collections/Uploads.js'
|
|||||||
import { Versions } from './collections/Versions.js'
|
import { Versions } from './collections/Versions.js'
|
||||||
import { seed } from './seed.js'
|
import { seed } from './seed.js'
|
||||||
import {
|
import {
|
||||||
|
categoriesJoinRestrictedSlug,
|
||||||
|
collectionRestrictedSlug,
|
||||||
localizedCategoriesSlug,
|
localizedCategoriesSlug,
|
||||||
localizedPostsSlug,
|
localizedPostsSlug,
|
||||||
postsSlug,
|
postsSlug,
|
||||||
@@ -95,6 +97,25 @@ export default buildConfigWithDefaults({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: categoriesJoinRestrictedSlug,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'name',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// join collection with access.read: () => false which should not populate
|
||||||
|
name: 'collectionRestrictedJoin',
|
||||||
|
type: 'join',
|
||||||
|
collection: collectionRestrictedSlug,
|
||||||
|
on: 'category',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: restrictedPostsSlug,
|
slug: restrictedPostsSlug,
|
||||||
admin: {
|
admin: {
|
||||||
@@ -120,6 +141,31 @@ export default buildConfigWithDefaults({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: collectionRestrictedSlug,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: () => ({ canRead: { equals: true } }),
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'canRead',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'category',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: restrictedCategoriesSlug,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
localization: {
|
localization: {
|
||||||
locales: ['en', 'es'],
|
locales: ['en', 'es'],
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
|||||||
import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js'
|
import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js'
|
||||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||||
import { categoriesSlug, postsSlug, uploadsSlug } from './shared.js'
|
import { categoriesJoinRestrictedSlug, categoriesSlug, postsSlug, uploadsSlug } from './shared.js'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -30,16 +30,16 @@ test.describe('Admin Panel', () => {
|
|||||||
let page: Page
|
let page: Page
|
||||||
let categoriesURL: AdminUrlUtil
|
let categoriesURL: AdminUrlUtil
|
||||||
let uploadsURL: AdminUrlUtil
|
let uploadsURL: AdminUrlUtil
|
||||||
let postsURL: AdminUrlUtil
|
let categoriesJoinRestrictedURL: AdminUrlUtil
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }, testInfo) => {
|
test.beforeAll(async ({ browser }, testInfo) => {
|
||||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||||
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
|
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
|
||||||
dirname,
|
dirname,
|
||||||
}))
|
}))
|
||||||
postsURL = new AdminUrlUtil(serverURL, postsSlug)
|
|
||||||
categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug)
|
categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug)
|
||||||
uploadsURL = new AdminUrlUtil(serverURL, uploadsSlug)
|
uploadsURL = new AdminUrlUtil(serverURL, uploadsSlug)
|
||||||
|
categoriesJoinRestrictedURL = new AdminUrlUtil(serverURL, categoriesJoinRestrictedSlug)
|
||||||
|
|
||||||
const context = await browser.newContext()
|
const context = await browser.newContext()
|
||||||
page = await context.newPage()
|
page = await context.newPage()
|
||||||
@@ -310,4 +310,14 @@ test.describe('Admin Panel', () => {
|
|||||||
}),
|
}),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should render initial rows within relationship table respecting access control', async () => {
|
||||||
|
await navigateToDoc(page, categoriesJoinRestrictedURL)
|
||||||
|
const joinField = page.locator('#field-collectionRestrictedJoin.field-type.join')
|
||||||
|
await expect(joinField).toBeVisible()
|
||||||
|
await expect(joinField.locator('.relationship-table table')).toBeVisible()
|
||||||
|
const rows = joinField.locator('.relationship-table tbody tr')
|
||||||
|
await expect(rows).toHaveCount(1)
|
||||||
|
await expect(joinField.locator('.cell-canRead')).not.toContainText('false')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { devUser } from '../credentials.js'
|
|||||||
import { idToString } from '../helpers/idToString.js'
|
import { idToString } from '../helpers/idToString.js'
|
||||||
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
||||||
import {
|
import {
|
||||||
|
categoriesJoinRestrictedSlug,
|
||||||
categoriesSlug,
|
categoriesSlug,
|
||||||
postsSlug,
|
postsSlug,
|
||||||
restrictedCategoriesSlug,
|
restrictedCategoriesSlug,
|
||||||
@@ -554,6 +555,22 @@ describe('Joins Field', () => {
|
|||||||
expect(unlimited.docs[0].relatedPosts.hasNextPage).toStrictEqual(false)
|
expect(unlimited.docs[0].relatedPosts.hasNextPage).toStrictEqual(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should respect access control for join collections', async () => {
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: categoriesJoinRestrictedSlug,
|
||||||
|
where: {
|
||||||
|
name: { equals: 'categoryJoinRestricted' },
|
||||||
|
},
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
})
|
||||||
|
const [categoryWithRestrictedPosts] = docs
|
||||||
|
expect(categoryWithRestrictedPosts.collectionRestrictedJoin.docs).toHaveLength(1)
|
||||||
|
expect(categoryWithRestrictedPosts.collectionRestrictedJoin.docs[0].title).toStrictEqual(
|
||||||
|
'should allow read',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should respect access control for join request `where` queries', async () => {
|
it('should respect access control for join request `where` queries', async () => {
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await payload.findByID({
|
await payload.findByID({
|
||||||
@@ -579,7 +596,7 @@ describe('Joins Field', () => {
|
|||||||
name: 'restricted category',
|
name: 'restricted category',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const post = await createPost({
|
await createPost({
|
||||||
collection: restrictedPostsSlug,
|
collection: restrictedPostsSlug,
|
||||||
data: {
|
data: {
|
||||||
title: 'restricted post',
|
title: 'restricted post',
|
||||||
@@ -776,6 +793,31 @@ describe('Joins Field', () => {
|
|||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
expect(response.data.Category.relatedPosts.docs[0].title).toStrictEqual('test 3')
|
expect(response.data.Category.relatedPosts.docs[0].title).toStrictEqual('test 3')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should respect access control for join collections', async () => {
|
||||||
|
const query = `query {
|
||||||
|
CategoriesJoinRestricteds {
|
||||||
|
docs {
|
||||||
|
name
|
||||||
|
collectionRestrictedJoin {
|
||||||
|
docs {
|
||||||
|
title
|
||||||
|
canRead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const response = await restClient
|
||||||
|
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||||
|
.then((res) => res.json())
|
||||||
|
const [categoryWithRestrictedPosts] = response.data.CategoriesJoinRestricteds.docs
|
||||||
|
expect(categoryWithRestrictedPosts.collectionRestrictedJoin.docs).toHaveLength(1)
|
||||||
|
expect(categoryWithRestrictedPosts.collectionRestrictedJoin.docs[0].title).toStrictEqual(
|
||||||
|
'should allow read',
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should work id.in command delimited querying with joins', async () => {
|
it('should work id.in command delimited querying with joins', async () => {
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export interface Config {
|
|||||||
'localized-posts': LocalizedPost;
|
'localized-posts': LocalizedPost;
|
||||||
'localized-categories': LocalizedCategory;
|
'localized-categories': LocalizedCategory;
|
||||||
'restricted-categories': RestrictedCategory;
|
'restricted-categories': RestrictedCategory;
|
||||||
|
'categories-join-restricted': CategoriesJoinRestricted;
|
||||||
'restricted-posts': RestrictedPost;
|
'restricted-posts': RestrictedPost;
|
||||||
|
'collection-restricted': CollectionRestricted;
|
||||||
users: User;
|
users: User;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
@@ -51,6 +53,9 @@ export interface Config {
|
|||||||
'restricted-categories': {
|
'restricted-categories': {
|
||||||
restrictedPosts: 'posts';
|
restrictedPosts: 'posts';
|
||||||
};
|
};
|
||||||
|
'categories-join-restricted': {
|
||||||
|
collectionRestrictedJoin: 'collection-restricted';
|
||||||
|
};
|
||||||
};
|
};
|
||||||
collectionsSelect: {
|
collectionsSelect: {
|
||||||
posts: PostsSelect<false> | PostsSelect<true>;
|
posts: PostsSelect<false> | PostsSelect<true>;
|
||||||
@@ -63,7 +68,9 @@ export interface Config {
|
|||||||
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
|
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
|
||||||
'localized-categories': LocalizedCategoriesSelect<false> | LocalizedCategoriesSelect<true>;
|
'localized-categories': LocalizedCategoriesSelect<false> | LocalizedCategoriesSelect<true>;
|
||||||
'restricted-categories': RestrictedCategoriesSelect<false> | RestrictedCategoriesSelect<true>;
|
'restricted-categories': RestrictedCategoriesSelect<false> | RestrictedCategoriesSelect<true>;
|
||||||
|
'categories-join-restricted': CategoriesJoinRestrictedSelect<false> | CategoriesJoinRestrictedSelect<true>;
|
||||||
'restricted-posts': RestrictedPostsSelect<false> | RestrictedPostsSelect<true>;
|
'restricted-posts': RestrictedPostsSelect<false> | RestrictedPostsSelect<true>;
|
||||||
|
'collection-restricted': CollectionRestrictedSelect<false> | CollectionRestrictedSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
@@ -278,6 +285,32 @@ export interface RestrictedCategory {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "categories-join-restricted".
|
||||||
|
*/
|
||||||
|
export interface CategoriesJoinRestricted {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
collectionRestrictedJoin?: {
|
||||||
|
docs?: (string | CollectionRestricted)[] | null;
|
||||||
|
hasNextPage?: boolean | null;
|
||||||
|
} | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "collection-restricted".
|
||||||
|
*/
|
||||||
|
export interface CollectionRestricted {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
canRead?: boolean | null;
|
||||||
|
category?: (string | null) | RestrictedCategory;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "restricted-posts".
|
* via the `definition` "restricted-posts".
|
||||||
@@ -354,10 +387,18 @@ export interface PayloadLockedDocument {
|
|||||||
relationTo: 'restricted-categories';
|
relationTo: 'restricted-categories';
|
||||||
value: string | RestrictedCategory;
|
value: string | RestrictedCategory;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'categories-join-restricted';
|
||||||
|
value: string | CategoriesJoinRestricted;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'restricted-posts';
|
relationTo: 'restricted-posts';
|
||||||
value: string | RestrictedPost;
|
value: string | RestrictedPost;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'collection-restricted';
|
||||||
|
value: string | CollectionRestricted;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: string | User;
|
||||||
@@ -536,6 +577,16 @@ export interface RestrictedCategoriesSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "categories-join-restricted_select".
|
||||||
|
*/
|
||||||
|
export interface CategoriesJoinRestrictedSelect<T extends boolean = true> {
|
||||||
|
name?: T;
|
||||||
|
collectionRestrictedJoin?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "restricted-posts_select".
|
* via the `definition` "restricted-posts_select".
|
||||||
@@ -547,6 +598,17 @@ export interface RestrictedPostsSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "collection-restricted_select".
|
||||||
|
*/
|
||||||
|
export interface CollectionRestrictedSelect<T extends boolean = true> {
|
||||||
|
title?: T;
|
||||||
|
canRead?: T;
|
||||||
|
category?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users_select".
|
* via the `definition` "users_select".
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { fileURLToPath } from 'url'
|
|||||||
import { devUser } from '../credentials.js'
|
import { devUser } from '../credentials.js'
|
||||||
import { seedDB } from '../helpers/seed.js'
|
import { seedDB } from '../helpers/seed.js'
|
||||||
import {
|
import {
|
||||||
|
categoriesJoinRestrictedSlug,
|
||||||
categoriesSlug,
|
categoriesSlug,
|
||||||
|
collectionRestrictedSlug,
|
||||||
collectionSlugs,
|
collectionSlugs,
|
||||||
hiddenPostsSlug,
|
hiddenPostsSlug,
|
||||||
postsSlug,
|
postsSlug,
|
||||||
@@ -91,6 +93,29 @@ export const seed = async (_payload) => {
|
|||||||
upload: uploadedImage.id,
|
upload: uploadedImage.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const restrictedCategory = await _payload.create({
|
||||||
|
collection: categoriesJoinRestrictedSlug,
|
||||||
|
data: {
|
||||||
|
name: 'categoryJoinRestricted',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await _payload.create({
|
||||||
|
collection: collectionRestrictedSlug,
|
||||||
|
data: {
|
||||||
|
title: 'should not allow read',
|
||||||
|
canRead: false,
|
||||||
|
category: restrictedCategory.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await _payload.create({
|
||||||
|
collection: collectionRestrictedSlug,
|
||||||
|
data: {
|
||||||
|
title: 'should allow read',
|
||||||
|
canRead: true,
|
||||||
|
category: restrictedCategory.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAndSeedEverything(_payload: Payload) {
|
export async function clearAndSeedEverything(_payload: Payload) {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export const localizedCategoriesSlug = 'localized-categories'
|
|||||||
|
|
||||||
export const restrictedPostsSlug = 'restricted-posts'
|
export const restrictedPostsSlug = 'restricted-posts'
|
||||||
|
|
||||||
|
export const categoriesJoinRestrictedSlug = 'categories-join-restricted'
|
||||||
|
|
||||||
|
export const collectionRestrictedSlug = 'collection-restricted'
|
||||||
|
|
||||||
export const restrictedCategoriesSlug = 'restricted-categories'
|
export const restrictedCategoriesSlug = 'restricted-categories'
|
||||||
|
|
||||||
export const collectionSlugs = [
|
export const collectionSlugs = [
|
||||||
|
|||||||
Reference in New Issue
Block a user