diff --git a/.env.example b/.env.example index c1198c6..db72ada 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ DATABASE_URL='mongodb://' PAYLOAD_SECRET= # openssl rand -hex 32 NEXT_PUBLIC_SERVER_URL=http://localhost:3000 +DRAFT_SECRET_TOKEN= # openssl rand -hex 32 SMTP_HOST=email-smtp.ap-southeast-1.amazonaws.com SMTP_USER= diff --git a/src/access/admin.ts b/src/access/admin.ts index 0b030d6..ccef2e7 100644 --- a/src/access/admin.ts +++ b/src/access/admin.ts @@ -6,5 +6,6 @@ import { USER_ROLES } from '@/constants/userRoles' type isAuthenticated = (args: AccessArgs) => boolean export const admin: isAuthenticated = ({ req: { user } }) => { - return Boolean(user && user.role.includes(USER_ROLES.ADMIN)) + if (!user || !('role' in user) || !user.role) return false + return Boolean(user.role.includes(USER_ROLES.ADMIN)) } diff --git a/src/access/adminOrSelf.ts b/src/access/adminOrSelf.ts index 853dd99..c1612ac 100644 --- a/src/access/adminOrSelf.ts +++ b/src/access/adminOrSelf.ts @@ -3,8 +3,10 @@ import type { Access } from 'payload' export const adminOrSelf: Access = ({ req: { user } }) => { if (!user) return false - return ( - user.role.includes(USER_ROLES.ADMIN) || - user.role.includes(USER_ROLES.SUPER_ADMIN) || { id: { equals: user.id } } - ) + if ('role' in user && user.role) { + if (user.role.includes(USER_ROLES.ADMIN) || user.role.includes(USER_ROLES.SUPER_ADMIN)) { + return true + } + } + return { id: { equals: user.id } } } diff --git a/src/access/anyone.ts b/src/access/anyone.ts index bf37c3a..ca34619 100644 --- a/src/access/anyone.ts +++ b/src/access/anyone.ts @@ -1,3 +1,31 @@ import type { Access } from 'payload' -export const anyone: Access = () => true +export const anyone: Access = ({ req: { user, headers, query, routeParams, pathname } }) => { + if (user) { + return true + } + + const authorization = headers.get('authorization') + if ( + authorization && + process.env.DRAFT_SECRET_TOKEN && + authorization === `Bearer ${process.env.DRAFT_SECRET_TOKEN}` + ) { + return true + } + + const isGlobalRequest = + typeof routeParams?.global === 'string' || + (typeof pathname === 'string' && pathname.includes('/api/globals/')) + + // Global reads should return boolean access, not a where clause. + if (isGlobalRequest) { + const draftParam = query?.draft + const isDraftRequested = draftParam === true || draftParam === 'true' + return !isDraftRequested + } + + return { + or: [{ _status: { equals: 'published' } }, { _status: { exists: false } }], + } +} diff --git a/src/access/checkResourceAccess.ts b/src/access/checkResourceAccess.ts index 39b82e4..a21ee8f 100644 --- a/src/access/checkResourceAccess.ts +++ b/src/access/checkResourceAccess.ts @@ -35,7 +35,8 @@ export function checkResourceAccess( if (slugType === 'group') { const groupItems = getSidebarGroupItems(assignedSlug as SidebarGroupSlug) - isApplicable = groupItems.includes(resourceSlug as CollectionSlug) + isApplicable = + groupItems.includes(resourceSlug as CollectionSlug) || assignedSlug === resourceSlug } else if (slugType === 'collection') { isApplicable = assignedSlug === resourceSlug } diff --git a/src/access/superAdmin.ts b/src/access/superAdmin.ts index 5da5269..e13eb43 100644 --- a/src/access/superAdmin.ts +++ b/src/access/superAdmin.ts @@ -3,5 +3,6 @@ import type { AccessArgs } from 'payload' import type { User } from '@/payload-types' export const superAdmin = ({ req: { user } }: AccessArgs) => { - return Boolean(user && user.role.includes('super-admin')) + if (!user || !('role' in user) || !user.role) return false + return Boolean(user.role.includes('super-admin')) } diff --git a/src/collections/ServiceAccounts.ts b/src/collections/ServiceAccounts.ts index 79ba358..b4c6878 100644 --- a/src/collections/ServiceAccounts.ts +++ b/src/collections/ServiceAccounts.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload' import { anyAdmin } from '@/access/anyAdmin' -import { USER_ROLE_LABELS, USER_ROLES } from '@/constants/userRoles' +import { USER_ROLES } from '@/constants/userRoles' import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections' import { adminOrSelf } from '@/access/adminOrSelf' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' @@ -21,10 +21,10 @@ export const ServiceAccounts: CollectionConfig = { update: adminOrSelf, }, admin: { - defaultColumns: ['name', 'email', 'createdAt'], + defaultColumns: ['name', 'createdAt'], useAsTitle: 'name', hidden({ user }) { - if (!user) return true + if (!user || !user.role) return true return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) }, group: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN), @@ -40,21 +40,6 @@ export const ServiceAccounts: CollectionConfig = { required: true, label: 'Service Account Name', }, - { - name: 'role', - type: 'select', - required: true, - saveToJWT: true, - hasMany: true, - access: { - create: ({ req }) => anyAdmin({ req }), - update: ({ req }) => anyAdmin({ req }), - }, - options: Object.values(USER_ROLES).map((value) => ({ - value, - label: USER_ROLE_LABELS[value], - })), - }, { name: 'permissions', type: 'array', diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts index bbd7416..fe938eb 100644 --- a/src/collections/Users/index.ts +++ b/src/collections/Users/index.ts @@ -33,8 +33,9 @@ export const Users: CollectionConfig = { }, access: { admin: ({ req: { user } }) => { + if (!user || !('role' in user) || !user.role) return false const allowedRoles = Object.values(USER_ROLES) - return Boolean(user && user.role.some((role) => allowedRoles.includes(role))) + return Boolean(user.role.some((r) => allowedRoles.includes(r))) }, create: anyAdmin, delete: anyAdmin, @@ -48,7 +49,7 @@ export const Users: CollectionConfig = { defaultColumns: ['email'], useAsTitle: 'email', hidden({ user }) { - if (!user) return true + if (!user || !('role' in user) || !user.role) return true return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) }, group: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN), diff --git a/src/endpoints/durianpy-website-types/index.ts b/src/endpoints/durianpy-website-types/index.ts new file mode 100644 index 0000000..038025b --- /dev/null +++ b/src/endpoints/durianpy-website-types/index.ts @@ -0,0 +1,167 @@ +import fs from 'fs' +import path from 'path' +import { checkResourceAccess } from '@/access/checkResourceAccess' +import { SIDEBAR_GROUPS, getSidebarGroupItems } from '@/constants/sidebarGroup' + +import { APIError, type Endpoint } from 'payload' + +const GROUP_SLUG = SIDEBAR_GROUPS.DURIANPY_WEBSITE + +function getCommentStartBefore(content: string, index: number): number { + const commentEnd = content.lastIndexOf('*/', index) + if (commentEnd === -1) return index + + const commentStart = content.lastIndexOf('/**', commentEnd) + if (commentStart === -1) return index + + const between = content.slice(commentEnd + 2, index) + return /^\s*$/.test(between) ? commentStart : index +} + +function extractInterfaceBlock(content: string, interfaceName: string): string | null { + const declaration = `export interface ${interfaceName}` + const declarationIndex = content.indexOf(declaration) + + if (declarationIndex === -1) return null + + const startIndex = getCommentStartBefore(content, declarationIndex) + const braceStart = content.indexOf('{', declarationIndex) + + if (braceStart === -1) return null + + let braceDepth = 0 + for (let i = braceStart; i < content.length; i += 1) { + const char = content[i] + + if (char === '{') braceDepth += 1 + if (char === '}') braceDepth -= 1 + + if (braceDepth === 0) { + return content.slice(startIndex, i + 1) + } + } + + return null +} + +function getTypeMapFromConfig(typesFileContent: string): Map { + const map = new Map() + + const extractEntriesFromBlock = (blockMatch: RegExpMatchArray | null) => { + if (!blockMatch) return + const blockContent = blockMatch[1] + const entryRegex = /^\s*(?:'([^']+)'|([A-Za-z0-9_-]+)):\s*([A-Za-z0-9_]+);\s*$/gm + let entryMatch: RegExpExecArray | null + while ((entryMatch = entryRegex.exec(blockContent)) !== null) { + const slug = entryMatch[1] ?? entryMatch[2] + const typeName = entryMatch[3] + if (slug && typeName) { + map.set(slug, typeName) + } + } + } + + extractEntriesFromBlock(typesFileContent.match(/collections:\s*\{([\s\S]*?)\n\s*\};/)) + extractEntriesFromBlock(typesFileContent.match(/globals:\s*\{([\s\S]*?)\n\s*\};/)) + + return map +} + +export const durianpyWebsiteTypesEndpoint: Endpoint = { + method: 'get', + path: '/durianpy-website-types', + handler: async (req) => { + if (!req.user && req.payload) { + const { user } = await req.payload.auth({ headers: req.headers }) + req.user = user + } + + if (!req.user) { + throw new APIError('Unauthorized', 401) + } + + const hasAccess = checkResourceAccess({ req }, GROUP_SLUG, 'read') + if (!hasAccess) { + throw new APIError('Forbidden', 403) + } + + try { + const fullTypes = fs.readFileSync( + path.resolve(process.cwd(), 'src/payload-types.ts'), + 'utf-8', + ) + + const groupResourceSlugs = [...getSidebarGroupItems(GROUP_SLUG)].sort((a, b) => + a.localeCompare(b), + ) + + const resourceTypeMap = getTypeMapFromConfig(fullTypes) + + const missingTypeMappings: string[] = [] + const selectedTypeNames = groupResourceSlugs + .map((slug) => { + const typeName = resourceTypeMap.get(slug) + if (!typeName) missingTypeMappings.push(slug) + return typeName + }) + .filter((name): name is string => Boolean(name)) + + if (missingTypeMappings.length > 0) { + return new Response( + JSON.stringify({ + error: 'Missing resource to type mappings in payload-types.ts', + group: GROUP_SLUG, + missingResourceSlugs: missingTypeMappings, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const missingInterfaceBlocks: string[] = [] + const extractedInterfaces = selectedTypeNames + .map((typeName) => { + const block = extractInterfaceBlock(fullTypes, typeName) + if (!block) missingInterfaceBlocks.push(typeName) + return block + }) + .filter((block): block is string => Boolean(block)) + + if (missingInterfaceBlocks.length > 0) { + return new Response( + JSON.stringify({ + error: 'Could not extract one or more interfaces from payload-types.ts', + group: GROUP_SLUG, + missingInterfaces: missingInterfaceBlocks, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const typeSyncHeader = `// Auto-generated from src/payload-types.ts\n// Group: ${GROUP_SLUG}\n// Resources: ${groupResourceSlugs.join(', ')}` + const payload = `${typeSyncHeader}\n\n${extractedInterfaces.join('\n\n')}` + + return new Response(payload, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + if (error instanceof APIError) { + throw error + } + throw new APIError( + error instanceof Error + ? error.message + : 'Failed to generate durianpy-website type sync payload', + 500, + ) + } + }, +} diff --git a/src/payload-types.ts b/src/payload-types.ts index 77df88a..ed45087 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -390,7 +390,6 @@ export interface Sample { export interface ServiceAccount { id: string; name: string; - role: ('super-admin' | 'admin' | 'writer' | 'reader')[]; permissions?: | { resource: @@ -1035,7 +1034,6 @@ export interface SampleSelect { */ export interface ServiceAccountsSelect { name?: T; - role?: T; permissions?: | T | { diff --git a/src/payload.config.ts b/src/payload.config.ts index 3beaf73..e5ae8e2 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -23,6 +23,7 @@ import { Sponsors } from './collections/durianpy-website/Sponsors' import { Carousel } from './globals/durianpy-website/Carousel' import { SIGs } from './collections/durianpy-website/SIGs' import { CodeOfConduct } from './globals/durianpy-website/CodeOfConduct' +import { durianpyWebsiteTypesEndpoint } from './endpoints/durianpy-website-types' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -103,6 +104,7 @@ export default buildConfig({ }), }), collections: [Media, Categories, Users, Sample, ServiceAccounts, Events, Sponsors, SIGs], + endpoints: [durianpyWebsiteTypesEndpoint], globals: [HomepageConfig, CTASection, StatisticsConfig, Carousel, CodeOfConduct], cors: [getServerSideOrigin()].filter(Boolean).map((url) => { try { diff --git a/src/seed/admin/collections/ServiceAccounts.ts b/src/seed/admin/collections/ServiceAccounts.ts index 337fc64..abcc153 100644 --- a/src/seed/admin/collections/ServiceAccounts.ts +++ b/src/seed/admin/collections/ServiceAccounts.ts @@ -1,6 +1,7 @@ import { Payload, PayloadRequest } from 'payload' import { COLLECTIONS } from '@/constants/collections' -import { USER_ROLES } from '@/constants/userRoles' +import { PERMISSIONS } from '@/constants/permissions' +import { SIDEBAR_GROUPS } from '@/constants/sidebarGroup' export async function seedServiceAccounts({ payload, @@ -12,12 +13,22 @@ export async function seedServiceAccounts({ const serviceAccounts = [ { name: 'GitHub Actions Bot', - role: [USER_ROLES.SUPER_ADMIN], + permissions: [ + { + resource: SIDEBAR_GROUPS.ADMIN, + accessLevel: PERMISSIONS.FULL_ACCESS, + }, + ], enableAPIKey: true, }, { name: 'Integration Service', - role: [USER_ROLES.ADMIN], + permissions: [ + { + resource: SIDEBAR_GROUPS.DURIANPY_WEBSITE, + accessLevel: PERMISSIONS.READ, + }, + ], enableAPIKey: true, }, ] diff --git a/tests/int/api.int.spec.ts b/tests/int/api.int.spec.ts index 9bd5adb..1f59bbd 100644 --- a/tests/int/api.int.spec.ts +++ b/tests/int/api.int.spec.ts @@ -1,5 +1,6 @@ import { getPayload, Payload } from 'payload' import config from '@/payload.config' +import { durianpyWebsiteTypesEndpoint } from '@/endpoints/durianpy-website-types' import { describe, it, beforeAll, expect } from 'vitest' @@ -17,4 +18,66 @@ describe('API', () => { }) expect(users).toBeDefined() }) + + it('returns only durianpy-website collection interfaces when authorized via admin role', async () => { + const mockReq = { + user: { + role: ['admin'], + }, + } + const response = await ( + durianpyWebsiteTypesEndpoint.handler as (req: unknown) => Promise + )(mockReq) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/plain') + + const body = await response.text() + + expect(body).toContain('Group: durianpy-website') + expect(body).toContain('Collections: sample, users') + expect(body).toContain('export interface Sample') + expect(body).toContain('export interface User') + + // Ensure non-group collections are excluded. + expect(body).not.toContain('export interface Media') + expect(body).not.toContain('export interface Category') + }) + + it('returns only durianpy-website collection interfaces when authorized via group read permission', async () => { + const mockReq = { + user: { + permissions: [ + { + resource: 'durianpy-website', + accessLevel: 'read', + }, + ], + }, + } + const response = await ( + durianpyWebsiteTypesEndpoint.handler as (req: unknown) => Promise + )(mockReq) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/plain') + }) + + it('rejects unauthenticated access to website collection interfaces', async () => { + const unauthReq = {} + await expect( + (durianpyWebsiteTypesEndpoint.handler as (req: unknown) => Promise)(unauthReq), + ).rejects.toThrow('Unauthorized') + }) + + it('rejects forbidden access when user lacks read permission on website group', async () => { + const forbiddenReq = { + user: { + permissions: [], + }, + } + await expect( + (durianpyWebsiteTypesEndpoint.handler as (req: unknown) => Promise)(forbiddenReq), + ).rejects.toThrow('Forbidden') + }) }) diff --git a/tests/unit/access/checkResourceAccess.spec.ts b/tests/unit/access/checkResourceAccess.spec.ts index fd7a1be..188b6a4 100644 --- a/tests/unit/access/checkResourceAccess.spec.ts +++ b/tests/unit/access/checkResourceAccess.spec.ts @@ -90,6 +90,23 @@ describe('checkResourceAccess', () => { ).toBe(true) }) + it('should return true if checking group-level access directly and permission grants the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [ + { resource: SIDEBAR_GROUPS.DURIANPY_WEBSITE, accessLevel: PERMISSIONS.READ }, + ], + }, + }, + } as unknown as AccessArgs + expect(checkResourceAccess(args, SIDEBAR_GROUPS.DURIANPY_WEBSITE, ACCESS_TYPES.READ)).toBe( + true, + ) + }) + it('should return false if group contains the resource but access level does not grant the access type', () => { vi.mocked(anyAdmin).mockReturnValue(false) const args = {