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:
Dan Ribbens
2024-12-12 12:01:03 -05:00
committed by GitHub
parent d4d79c1141
commit 5af71fb8d0
8 changed files with 196 additions and 7 deletions

View File

@@ -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
}, },
} }

View File

@@ -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,

View File

@@ -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'],

View File

@@ -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')
})
}) })

View File

@@ -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 () => {

View File

@@ -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".

View File

@@ -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) {

View File

@@ -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 = [