From 23e44a08b22737292cda76490953faa20680ffdd Mon Sep 17 00:00:00 2001 From: Alvin Sebastian Date: Thu, 18 Jun 2026 00:38:08 +0000 Subject: [PATCH 1/4] feat: add access restriction --- src/app.ts | 4 ++-- src/routes/crate.test.ts | 7 ++++++- src/routes/crate.ts | 19 ++++++++++++++++++- src/routes/entity.ts | 1 + src/routes/file.test.ts | 7 ++++++- src/routes/file.ts | 27 ++++++++++++++++++++++++++- src/routes/files.ts | 16 +++++++++++++--- src/transformers/default.ts | 6 ++++-- src/types/transformers.ts | 4 ++-- 9 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/app.ts b/src/app.ts index f511176..7be13e2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -132,8 +132,8 @@ const app: FastifyPluginAsync = async (fastify, options) => { fastify.register(entities, { prisma, accessTransformer, entityTransformers }); fastify.register(entity, { prisma, accessTransformer, entityTransformers }); fastify.register(files, { prisma, fileAccessTransformer, fileTransformers }); - fastify.register(file, { prisma, fileHandler }); - fastify.register(crate, { prisma, roCrateHandler }); + fastify.register(file, { prisma, fileAccessTransformer, fileHandler }); + fastify.register(crate, { prisma, accessTransformer, roCrateHandler }); fastify.register(search, { prisma, opensearch, diff --git a/src/routes/crate.test.ts b/src/routes/crate.test.ts index 344f459..53094fe 100644 --- a/src/routes/crate.test.ts +++ b/src/routes/crate.test.ts @@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js'; +import { AllPublicAccessTransformer } from '../transformers/default.js'; import type { FileResult, RoCrateHandler } from '../types/fileHandlers.js'; import type { StandardErrorResponse } from '../utils/errors.js'; import crateRoute from './crate.js'; @@ -18,7 +19,11 @@ describe('Crate Route', () => { beforeEach(async () => { await fastifyBefore(); - await fastify.register(crateRoute, { prisma, roCrateHandler: mockRoCrateHandler }); + await fastify.register(crateRoute, { + prisma, + accessTransformer: AllPublicAccessTransformer, + roCrateHandler: mockRoCrateHandler, + }); vi.clearAllMocks(); }); diff --git a/src/routes/crate.ts b/src/routes/crate.ts index 01493cc..262282a 100644 --- a/src/routes/crate.ts +++ b/src/routes/crate.ts @@ -4,6 +4,7 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; import type { FileMetadata, RoCrateHandler } from '../types/fileHandlers.js'; +import type { AccessTransformer } from '../types/transformers.js'; import { createInternalError, createNotFoundError } from '../utils/errors.js'; import { setFileHeaders } from '../utils/headers.js'; @@ -13,11 +14,12 @@ const paramsSchema = z.object({ type CrateRouteOptions = { prisma: PrismaClient; + accessTransformer: AccessTransformer; roCrateHandler: RoCrateHandler; }; const crate: FastifyPluginAsync = async (fastify, opts) => { - const { prisma, roCrateHandler } = opts; + const { prisma, accessTransformer, roCrateHandler } = opts; fastify.withTypeProvider().head( '/entity/:id/rocrate', @@ -37,6 +39,13 @@ const crate: FastifyPluginAsync = async (fastify, opts) => { if (!entity) { return reply.code(404).send(createNotFoundError('The requested entity was not found', id)); } + const standardEntity = { + ...entity, + memberOf: { id: entity.memberOf || '', name: '' }, + rootCollection: { id: entity.rootCollection || '', name: '' }, + }; + const authorisedEntity = await accessTransformer(standardEntity, { request, fastify }); + if (!authorisedEntity.access.metadata) return reply.forbidden('Access to this resource is restricted'); const metadata: FileMetadata | false = await roCrateHandler.head(entity, { request, fastify }); @@ -75,6 +84,14 @@ const crate: FastifyPluginAsync = async (fastify, opts) => { return reply.code(404).send(createNotFoundError('The requested entity was not found', id)); } + const standardEntity = { + ...entity, + memberOf: { id: entity.memberOf || '', name: '' }, + rootCollection: { id: entity.rootCollection || '', name: '' }, + }; + const authorisedEntity = await accessTransformer(standardEntity, { request, fastify }); + if (!authorisedEntity.access.metadata) return reply.forbidden('Access to this resource is restricted'); + const result = await roCrateHandler.get(entity, { request, fastify }); if (!result) { diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 2ad7f38..d4fa7af 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -49,6 +49,7 @@ const entity: FastifyPluginAsync = async (fastify, opts) => }; const authorisedEntity = await accessTransformer(standardEntity, { request, fastify }); + if (!authorisedEntity.access.metadata) return reply.forbidden('Access to this resource is restricted'); let result = authorisedEntity; for (const transformer of entityTransformers) { result = await transformer(result, { request, fastify }); diff --git a/src/routes/file.test.ts b/src/routes/file.test.ts index de41493..0aaffff 100644 --- a/src/routes/file.test.ts +++ b/src/routes/file.test.ts @@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js'; +import { AllPublicFileAccessTransformer } from '../transformers/default.js'; import type { FileHandler, FileResult } from '../types/fileHandlers.js'; import type { StandardErrorResponse } from '../utils/errors.js'; import fileRoute from './file.js'; @@ -18,7 +19,11 @@ describe('File Route', () => { beforeEach(async () => { await fastifyBefore(); - await fastify.register(fileRoute, { prisma, fileHandler: mockFileHandler }); + await fastify.register(fileRoute, { + prisma, + fileAccessTransformer: AllPublicFileAccessTransformer, + fileHandler: mockFileHandler, + }); vi.clearAllMocks(); }); diff --git a/src/routes/file.ts b/src/routes/file.ts index dc0a1f1..7b9c6f2 100644 --- a/src/routes/file.ts +++ b/src/routes/file.ts @@ -3,7 +3,9 @@ import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; +import { baseFileTransformer, resolveEntityReferences } from '../transformers/default.js'; import type { FileHandler, FileMetadata } from '../types/fileHandlers.js'; +import type { FileAccessTransformer } from '../types/transformers.js'; import { createInternalError, createNotFoundError } from '../utils/errors.js'; import { setFileHeaders } from '../utils/headers.js'; @@ -19,11 +21,12 @@ const querySchema = z.object({ type FileRouteOptions = { prisma: PrismaClient; + fileAccessTransformer: FileAccessTransformer; fileHandler: FileHandler; }; const file: FastifyPluginAsync = async (fastify, opts) => { - const { prisma, fileHandler } = opts; + const { prisma, fileAccessTransformer, fileHandler } = opts; fastify.withTypeProvider().head( '/file/:id', @@ -38,12 +41,23 @@ const file: FastifyPluginAsync = async (fastify, opts) => { try { const file = await prisma.file.findUnique({ where: { id }, + include: { entity: true }, }); if (!file) { return reply.code(404).send(createNotFoundError('The requested file was not found', id)); } + const refMap = await resolveEntityReferences([{ ...file.entity }], prisma); + const entity = { + ...file.entity, + ...baseFileTransformer(file), + memberOf: file.entity.memberOf ? (refMap.get(file.entity.memberOf) ?? null) : null, + rootCollection: file.entity.rootCollection ? (refMap.get(file.entity.rootCollection) ?? null) : null, + }; + const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); + if (!authorisedFile.access.content) return reply.forbidden('Access to this resource is restricted'); + const metadata: FileMetadata | false = await fileHandler.head(file, { request, fastify }); if (!metadata) { @@ -76,12 +90,23 @@ const file: FastifyPluginAsync = async (fastify, opts) => { try { const file = await prisma.file.findUnique({ where: { id }, + include: { entity: true }, }); if (!file) { return reply.code(404).send(createNotFoundError('The requested file was not found', id)); } + const refMap = await resolveEntityReferences([{ ...file.entity }], prisma); + const entity = { + ...file.entity, + ...baseFileTransformer(file), + memberOf: file.entity.memberOf ? (refMap.get(file.entity.memberOf) ?? null) : null, + rootCollection: file.entity.rootCollection ? (refMap.get(file.entity.rootCollection) ?? null) : null, + }; + const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); + if (!authorisedFile.access.content) return reply.forbidden('Access to this resource is restricted'); + const result = await fileHandler.get(file, { request, fastify }); if (!result) { diff --git a/src/routes/files.ts b/src/routes/files.ts index f524690..0192602 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; -import { baseFileTransformer } from '../transformers/default.js'; +import { baseFileTransformer, resolveEntityReferences } from '../transformers/default.js'; import type { FileAccessTransformer, FileTransformer } from '../types/transformers.js'; import { createInternalError } from '../utils/errors.js'; @@ -48,15 +48,25 @@ const files: FastifyPluginAsync = async (fastify, opts) => { }, skip: offset, take: limit, + include: { entity: true }, }), prisma.file.count({ where }), ]); + const refMap = await resolveEntityReferences( + dbFiles.map((f) => f.entity), + prisma, + ); // Apply transformers to each entity: base -> access -> additional const filesWithAccess = await Promise.all( dbFiles.map(async (dbFile) => { - const standardFile = baseFileTransformer(dbFile); - const authorisedFile = await fileAccessTransformer(standardFile, { request, fastify }); + const entity = { + ...dbFile.entity, + ...baseFileTransformer(dbFile), + memberOf: dbFile.entity.memberOf ? (refMap.get(dbFile.entity.memberOf) ?? null) : null, + rootCollection: dbFile.entity.rootCollection ? (refMap.get(dbFile.entity.rootCollection) ?? null) : null, + }; + const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); let result = authorisedFile; for (const transformer of fileTransformers) { diff --git a/src/transformers/default.ts b/src/transformers/default.ts index 602f336..3a37572 100644 --- a/src/transformers/default.ts +++ b/src/transformers/default.ts @@ -78,12 +78,14 @@ export type StandardFile = { size: number; }; +export type FileEntity = StandardFile & StandardEntity; + /** * Authorised file - includes access information * This is the output of the file access transformer * File metadata is always accessible - only content access is controlled */ -export type AuthorisedFile = StandardFile & { +export type AuthorisedFile = FileEntity & { access: FileAccessInfo; }; @@ -166,7 +168,7 @@ export const baseFileTransformer = (file: File): StandardFile => ({ * }); * ``` */ -export const AllPublicFileAccessTransformer = (file: StandardFile): AuthorisedFile => ({ +export const AllPublicFileAccessTransformer = (file: FileEntity): AuthorisedFile => ({ ...file, access: { content: true, diff --git a/src/types/transformers.ts b/src/types/transformers.ts index 32570d2..5ea88a1 100644 --- a/src/types/transformers.ts +++ b/src/types/transformers.ts @@ -1,5 +1,5 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; -import type { AuthorisedEntity, AuthorisedFile, StandardEntity, StandardFile } from '../transformers/default.js'; +import type { AuthorisedEntity, AuthorisedFile, FileEntity, StandardEntity } from '../transformers/default.js'; /** * Context provided to entity transformers @@ -36,7 +36,7 @@ export type EntityTransformer = ( * Only content access is controlled (access.content) */ export type FileAccessTransformer = ( - file: StandardFile, + file: FileEntity, context: TransformerContext, ) => Promise | AuthorisedFile; From 1f581e7ca6b6b15963eb8cb34c9d91a88284b93a Mon Sep 17 00:00:00 2001 From: Alvin Sebastian Date: Thu, 6 Aug 2026 23:04:51 +0000 Subject: [PATCH 2/4] feat: add valid licenses checker --- src/app.ts | 6 +++++- src/routes/entities.ts | 14 ++++++++++++-- src/routes/search.ts | 15 ++++++++++++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/app.ts b/src/app.ts index 7be13e2..84aa253 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import type { EntityTransformer, FileAccessTransformer, FileTransformer, + TransformerContext, } from './types/transformers.js'; import { createValidationError } from './utils/errors.js'; import type { QueryBuilderOptions } from './utils/queryBuilder.js'; @@ -83,6 +84,7 @@ export type Options = { fileTransformers?: FileTransformer[]; fileHandler: FileHandler; roCrateHandler: RoCrateHandler; + resolveValidLicenses?: (opt: TransformerContext) => Promise; }; const app: FastifyPluginAsync = async (fastify, options) => { const { @@ -97,6 +99,7 @@ const app: FastifyPluginAsync = async (fastify, options) => { fileTransformers, fileHandler, roCrateHandler, + resolveValidLicenses, } = options; if (!prisma) { @@ -129,7 +132,7 @@ const app: FastifyPluginAsync = async (fastify, options) => { } setupValidation(fastify); - fastify.register(entities, { prisma, accessTransformer, entityTransformers }); + fastify.register(entities, { prisma, accessTransformer, entityTransformers, resolveValidLicenses }); fastify.register(entity, { prisma, accessTransformer, entityTransformers }); fastify.register(files, { prisma, fileAccessTransformer, fileTransformers }); fastify.register(file, { prisma, fileAccessTransformer, fileHandler }); @@ -138,6 +141,7 @@ const app: FastifyPluginAsync = async (fastify, options) => { prisma, opensearch, accessTransformer, + resolveValidLicenses, entityTransformers, queryBuilderClass, queryBuilderOptions, diff --git a/src/routes/entities.ts b/src/routes/entities.ts index 25b32c4..e3bd1b4 100644 --- a/src/routes/entities.ts +++ b/src/routes/entities.ts @@ -3,7 +3,7 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js'; -import type { AccessTransformer, EntityTransformer } from '../types/transformers.js'; +import type { AccessTransformer, EntityTransformer, TransformerContext } from '../types/transformers.js'; import { createInternalError } from '../utils/errors.js'; const querySchema = z.object({ @@ -27,10 +27,11 @@ type EntitiesRouteOptions = { prisma: PrismaClient; accessTransformer: AccessTransformer; entityTransformers?: EntityTransformer[]; + resolveValidLicenses?: (opt: TransformerContext) => Promise; }; const entities: FastifyPluginAsync = async (fastify, opts) => { - const { prisma, accessTransformer, entityTransformers = [] } = opts; + const { prisma, accessTransformer, entityTransformers = [], resolveValidLicenses } = opts; fastify.withTypeProvider().get( '/entities', { @@ -54,6 +55,15 @@ const entities: FastifyPluginAsync = async (fastify, opts) }; } + if (resolveValidLicenses) { + const validLicenses = await resolveValidLicenses({ request, fastify }); + if (validLicenses?.length) { + where.metadataLicenseId = { + in: validLicenses, + }; + } + } + const [dbEntities, total] = await Promise.all([ prisma.entity.findMany({ where, diff --git a/src/routes/search.ts b/src/routes/search.ts index 39c58de..d4ae298 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -6,7 +6,7 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js'; -import type { AccessTransformer, EntityTransformer } from '../types/transformers.js'; +import type { AccessTransformer, EntityTransformer, TransformerContext } from '../types/transformers.js'; import { createInternalError, createInvalidRequestError } from '../utils/errors.js'; import { OpensearchQueryBuilder, type QueryBuilderOptions } from '../utils/queryBuilder.js'; @@ -38,6 +38,7 @@ type SearchRouteOptions = { opensearch: Client; accessTransformer: AccessTransformer; entityTransformers?: EntityTransformer[]; + resolveValidLicenses?: (opt: TransformerContext) => Promise; queryBuilderClass?: typeof OpensearchQueryBuilder; queryBuilderOptions?: QueryBuilderOptions; }; @@ -48,6 +49,7 @@ const search: FastifyPluginAsync = async (fastify, opts) => opensearch, accessTransformer, entityTransformers = [], + resolveValidLicenses, queryBuilderClass = OpensearchQueryBuilder, queryBuilderOptions, } = opts; @@ -60,8 +62,15 @@ const search: FastifyPluginAsync = async (fastify, opts) => }, }, async (request, reply) => { - const { searchType, query, filters, boundingBox, geohashPrecision, limit, offset, sort, order } = request.body; - + const { searchType, query, boundingBox, geohashPrecision, limit, offset, sort, order } = request.body; + let filters = request.body.filters; + if (resolveValidLicenses) { + const validLicenses = await resolveValidLicenses({ request, fastify }); + if (validLicenses?.length) { + filters = filters || {}; + filters.metadataLicenseId = validLicenses; + } + } try { const opensearchQuery: Search_Request = { index: 'entities', From 2f3a22dfad41960e201de0b44ff85b753e379834 Mon Sep 17 00:00:00 2001 From: Alvin Sebastian Date: Mon, 24 Aug 2026 01:11:29 +0000 Subject: [PATCH 3/4] feat: add test cases for access-restriction feature --- src/app.ts | 2 +- src/routes/__snapshots__/entity.test.ts.snap | 12 +++ src/routes/entities.test.ts | 39 +++++++++ src/routes/entity.test.ts | 42 +++++++++- src/routes/entity.ts | 5 +- src/routes/file.test.ts | 84 +++++++++++++++++++- src/routes/file.ts | 10 ++- src/routes/files.test.ts | 42 ++++++++++ src/routes/files.ts | 17 +++- src/test/helpers/fastify.ts | 21 +++++ src/utils/errors.ts | 4 + 11 files changed, 267 insertions(+), 11 deletions(-) diff --git a/src/app.ts b/src/app.ts index 84aa253..50d1889 100644 --- a/src/app.ts +++ b/src/app.ts @@ -134,7 +134,7 @@ const app: FastifyPluginAsync = async (fastify, options) => { fastify.register(entities, { prisma, accessTransformer, entityTransformers, resolveValidLicenses }); fastify.register(entity, { prisma, accessTransformer, entityTransformers }); - fastify.register(files, { prisma, fileAccessTransformer, fileTransformers }); + fastify.register(files, { prisma, fileAccessTransformer, fileTransformers, resolveValidLicenses }); fastify.register(file, { prisma, fileAccessTransformer, fileHandler }); fastify.register(crate, { prisma, accessTransformer, roCrateHandler }); fastify.register(search, { diff --git a/src/routes/__snapshots__/entity.test.ts.snap b/src/routes/__snapshots__/entity.test.ts.snap index 102f8b4..a3ed747 100644 --- a/src/routes/__snapshots__/entity.test.ts.snap +++ b/src/routes/__snapshots__/entity.test.ts.snap @@ -72,3 +72,15 @@ exports[`Entity Route > GET /entity/:id > should return null for memberOf/rootCo "rootCollection": null, } `; + +exports[`Entity Route Restricted > GET /entity/:id > should return 403 1`] = ` +{ + "error": { + "code": "FORBIDDEN", + "details": { + "entityId": "http://example.com/entity/123", + }, + "message": "Access to this resource is restricted", + }, +} +`; diff --git a/src/routes/entities.test.ts b/src/routes/entities.test.ts index 7b189c7..a3c543f 100644 --- a/src/routes/entities.test.ts +++ b/src/routes/entities.test.ts @@ -318,3 +318,42 @@ describe('Entities Route', () => { }); }); }); + +describe('Entities Route with License Filtering', () => { + async function resolveValidLicenses() { + return ['https://creativecommons.org/licenses/by/4.0/']; + } + beforeEach(async () => { + await fastifyBefore(); + await fastify.register(entitiesRoute, { + prisma, + accessTransformer: AllPublicAccessTransformer, + resolveValidLicenses, + }); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + describe('GET /entities', () => { + it('should filter by metadataLicenseId', async () => { + prisma.entity.findMany.mockResolvedValue([]); + prisma.entity.count.mockResolvedValue(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + }); + + expect(response.statusCode).toBe(200); + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: { metadataLicenseId: { in: await resolveValidLicenses() } }, + include: { file: { select: { id: true } } }, + orderBy: { id: 'asc' }, + skip: 0, + take: 100, + }); + }); + }); +}); diff --git a/src/routes/entity.test.ts b/src/routes/entity.test.ts index 0dea12d..d4e365f 100644 --- a/src/routes/entity.test.ts +++ b/src/routes/entity.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js'; +import { fastify, fastifyAfter, fastifyBefore, prisma, RestrictedAccessTransformer } from '../test/helpers/fastify.js'; import { AllPublicAccessTransformer } from '../transformers/default.js'; import type { StandardErrorResponse } from '../utils/errors.js'; import entityRoute from './entity.js'; @@ -158,3 +158,43 @@ describe('Entity Route', () => { }); }); }); + +describe('Entity Route Restricted', () => { + beforeEach(async () => { + await fastifyBefore(); + await fastify.register(entityRoute, { prisma, accessTransformer: RestrictedAccessTransformer }); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + describe('GET /entity/:id', () => { + it('should return 403', async () => { + const mockEntity = { + id: 'http://example.com/entity/123', + name: 'Test Entity', + description: 'A test entity', + entityType: 'http://schema.org/Person', + memberOf: null, + rootCollection: null, + metadataLicenseId: 'https://choosealicense.com/no-permission/', + contentLicenseId: 'https://choosealicense.com/no-permission/', + createdAt: new Date(), + updatedAt: new Date(), + meta: {}, + }; + + prisma.entity.findUnique.mockResolvedValue(mockEntity); + + const response = await fastify.inject({ + method: 'GET', + url: `/entity/${encodeURIComponent('http://example.com/entity/123')}`, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(403); + expect(body).toMatchSnapshot(); + }); + }); +}); diff --git a/src/routes/entity.ts b/src/routes/entity.ts index d4fa7af..f33eafb 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -4,7 +4,7 @@ import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js'; import type { AccessTransformer, EntityTransformer } from '../types/transformers.js'; -import { createInternalError, createNotFoundError } from '../utils/errors.js'; +import { createForbiddenError, createInternalError, createNotFoundError } from '../utils/errors.js'; const paramsSchema = z.object({ id: z.url(), @@ -49,7 +49,8 @@ const entity: FastifyPluginAsync = async (fastify, opts) => }; const authorisedEntity = await accessTransformer(standardEntity, { request, fastify }); - if (!authorisedEntity.access.metadata) return reply.forbidden('Access to this resource is restricted'); + if (!authorisedEntity.access.metadata) + return reply.code(403).send(createForbiddenError('Access to this resource is restricted', id)); let result = authorisedEntity; for (const transformer of entityTransformers) { result = await transformer(result, { request, fastify }); diff --git a/src/routes/file.test.ts b/src/routes/file.test.ts index 0aaffff..58ead5f 100644 --- a/src/routes/file.test.ts +++ b/src/routes/file.test.ts @@ -1,7 +1,13 @@ import { createReadStream } from 'node:fs'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js'; +import { + fastify, + fastifyAfter, + fastifyBefore, + prisma, + RestrictedFileAccessTransformer, +} from '../test/helpers/fastify.js'; import { AllPublicFileAccessTransformer } from '../transformers/default.js'; import type { FileHandler, FileResult } from '../types/fileHandlers.js'; import type { StandardErrorResponse } from '../utils/errors.js'; @@ -39,6 +45,9 @@ describe('File Route', () => { meta: { storagePath: '/data/files/test.wav' }, createdAt: new Date(), updatedAt: new Date(), + entity: { + memberOf: null, + }, }; describe('GET /file/:id', () => { @@ -398,3 +407,76 @@ describe('File Route', () => { }); }); }); + +describe('File Route Restricted', () => { + const mockFileHandler: FileHandler = { + get: vi.fn(), + head: vi.fn(), + }; + + beforeEach(async () => { + await fastifyBefore(); + await fastify.register(fileRoute, { + prisma, + fileAccessTransformer: RestrictedFileAccessTransformer, + fileHandler: mockFileHandler, + }); + vi.clearAllMocks(); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + const mockFile = { + id: 'http://example.com/file/test.wav', + filename: 'test.wav', + mediaType: 'audio/wav', + size: BigInt(1024), + meta: { storagePath: '/data/files/test.wav' }, + createdAt: new Date(), + updatedAt: new Date(), + entity: { + memberOf: null, + }, + }; + + describe('HEAD /file/:id', () => { + it('should return 403', async () => { + prisma.file.findUnique.mockResolvedValue(mockFile); + vi.mocked(mockFileHandler.get).mockResolvedValue({ + type: 'redirect', + url: 'https://storage.example.com/files/test.wav', + }); + + const response = await fastify.inject({ + method: 'HEAD', + url: `/file/${encodeURIComponent('http://example.com/file/test.wav')}`, + }); + const body = (await response.json()) as { error: { code: string; message: string } }; + + expect(response.statusCode).toBe(403); + expect(body.error.code).toBe('FORBIDDEN'); + expect(mockFileHandler.get).not.toHaveBeenCalled(); + }); + }); + + describe('GET /file/:id', () => { + it('should return 403', async () => { + prisma.file.findUnique.mockResolvedValue(mockFile); + vi.mocked(mockFileHandler.get).mockResolvedValue({ + type: 'redirect', + url: 'https://storage.example.com/files/test.wav', + }); + + const response = await fastify.inject({ + method: 'GET', + url: `/file/${encodeURIComponent('http://example.com/file/test.wav')}`, + }); + const body = (await response.json()) as { error: { code: string; message: string } }; + + expect(response.statusCode).toBe(403); + expect(body.error.code).toBe('FORBIDDEN'); + }); + }); +}); diff --git a/src/routes/file.ts b/src/routes/file.ts index 7b9c6f2..f4a7ab9 100644 --- a/src/routes/file.ts +++ b/src/routes/file.ts @@ -6,7 +6,7 @@ import type { PrismaClient } from '../generated/prisma/client.js'; import { baseFileTransformer, resolveEntityReferences } from '../transformers/default.js'; import type { FileHandler, FileMetadata } from '../types/fileHandlers.js'; import type { FileAccessTransformer } from '../types/transformers.js'; -import { createInternalError, createNotFoundError } from '../utils/errors.js'; +import { createForbiddenError, createInternalError, createNotFoundError } from '../utils/errors.js'; import { setFileHeaders } from '../utils/headers.js'; const paramsSchema = z.object({ @@ -56,7 +56,9 @@ const file: FastifyPluginAsync = async (fastify, opts) => { rootCollection: file.entity.rootCollection ? (refMap.get(file.entity.rootCollection) ?? null) : null, }; const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); - if (!authorisedFile.access.content) return reply.forbidden('Access to this resource is restricted'); + if (!authorisedFile.access.content) { + return reply.code(403).send(createForbiddenError('Access to this resource is restricted', id)); + } const metadata: FileMetadata | false = await fileHandler.head(file, { request, fastify }); @@ -105,7 +107,9 @@ const file: FastifyPluginAsync = async (fastify, opts) => { rootCollection: file.entity.rootCollection ? (refMap.get(file.entity.rootCollection) ?? null) : null, }; const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); - if (!authorisedFile.access.content) return reply.forbidden('Access to this resource is restricted'); + if (!authorisedFile.access.content) { + return reply.code(403).send(createForbiddenError('Access to this resource is restricted', id)); + } const result = await fileHandler.get(file, { request, fastify }); diff --git a/src/routes/files.test.ts b/src/routes/files.test.ts index 5b45b37..b52bfa1 100644 --- a/src/routes/files.test.ts +++ b/src/routes/files.test.ts @@ -23,6 +23,9 @@ describe('Files Route', () => { meta: {}, createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-01'), + entity: { + memberOf: null, + }, }; const mockFile2 = { @@ -33,6 +36,9 @@ describe('Files Route', () => { meta: {}, createdAt: new Date('2025-01-02'), updatedAt: new Date('2025-01-02'), + entity: { + memberOf: null, + }, }; describe('GET /files', () => { @@ -276,3 +282,39 @@ describe('Files Route', () => { }); }); }); + +describe('Files Route with License Filtering', () => { + async function resolveValidLicenses() { + return ['https://creativecommons.org/licenses/by/4.0/']; + } + beforeEach(async () => { + await fastifyBefore(); + await fastify.register(filesRoute, { + prisma, + fileAccessTransformer: AllPublicFileAccessTransformer, + resolveValidLicenses, + }); + }); + + afterEach(async () => { + await fastifyAfter(); + }); + + describe('GET /files', () => { + it('should filter by metadataLicenseId', async () => { + prisma.file.findMany.mockResolvedValue([]); + prisma.file.count.mockResolvedValue(0); + + const response = await fastify.inject({ + method: 'GET', + url: '/files', + }); + expect(response.statusCode).toBe(200); + expect(prisma.file.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { entity: { metadataLicenseId: { in: await resolveValidLicenses() } } }, + }), + ); + }); + }); +}); diff --git a/src/routes/files.ts b/src/routes/files.ts index 0192602..c9c2720 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -3,7 +3,7 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; import { baseFileTransformer, resolveEntityReferences } from '../transformers/default.js'; -import type { FileAccessTransformer, FileTransformer } from '../types/transformers.js'; +import type { FileAccessTransformer, FileTransformer, TransformerContext } from '../types/transformers.js'; import { createInternalError } from '../utils/errors.js'; const querySchema = z.object({ @@ -18,10 +18,11 @@ type FilesRouteOptions = { prisma: PrismaClient; fileAccessTransformer: FileAccessTransformer; fileTransformers?: FileTransformer[]; + resolveValidLicenses?: (opt: TransformerContext) => Promise; }; const files: FastifyPluginAsync = async (fastify, opts) => { - const { prisma, fileAccessTransformer, fileTransformers = [] } = opts; + const { prisma, fileAccessTransformer, fileTransformers, resolveValidLicenses } = opts; fastify.withTypeProvider().get( '/files', @@ -40,6 +41,16 @@ const files: FastifyPluginAsync = async (fastify, opts) => { where.entity = { memberOf }; } + if (resolveValidLicenses) { + const validLicenses = await resolveValidLicenses({ request, fastify }); + if (validLicenses?.length) { + where.entity = where.entity || {}; + where.entity.metadataLicenseId = { + in: validLicenses, + }; + } + } + const [dbFiles, total] = await Promise.all([ prisma.file.findMany({ where, @@ -69,7 +80,7 @@ const files: FastifyPluginAsync = async (fastify, opts) => { const authorisedFile = await fileAccessTransformer(entity, { request, fastify }); let result = authorisedFile; - for (const transformer of fileTransformers) { + for (const transformer of fileTransformers || []) { result = await transformer(result, { request, fastify }); } diff --git a/src/test/helpers/fastify.ts b/src/test/helpers/fastify.ts index 54ab362..c10affc 100644 --- a/src/test/helpers/fastify.ts +++ b/src/test/helpers/fastify.ts @@ -5,6 +5,7 @@ import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod import { mockDeep, mockReset } from 'vitest-mock-extended'; import type { PrismaClient } from '../../generated/prisma/client.js'; +import type { AccessTransformer, FileAccessTransformer } from '../../types/transformers.js'; export let fastify: FastifyInstance; export const prisma = mockDeep(); @@ -25,3 +26,23 @@ export const fastifyBefore = async () => { export const fastifyAfter = async () => { await fastify.close(); }; + +export const RestrictedAccessTransformer: AccessTransformer = (entity) => ({ + ...entity, + access: { + metadata: false, + content: false, + metadataAuthorizationUrl: '', + contentAuthorizationUrl: '', + }, +}); + +export const RestrictedFileAccessTransformer: FileAccessTransformer = (file) => ({ + ...file, + access: { + metadata: false, + content: false, + metadataAuthorizationUrl: '', + contentAuthorizationUrl: '', + }, +}); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 7ad982d..cf3d3c9 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -5,6 +5,7 @@ const ERROR_CODES = { INTERNAL_ERROR: 'INTERNAL_ERROR', INVALID_REQUEST: 'INVALID_REQUEST', INVALID_ENTITY_TYPE: 'INVALID_ENTITY_TYPE', + FORBIDDEN: 'FORBIDDEN', } as const; type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; @@ -47,3 +48,6 @@ export const createInvalidRequestError = (message: string): StandardErrorRespons export const createInternalError = (message = 'Internal server error'): StandardErrorResponse => { return createErrorResponse(ERROR_CODES.INTERNAL_ERROR, message); }; + +export const createForbiddenError = (message: string, entityId?: string): StandardErrorResponse => + createErrorResponse(ERROR_CODES.FORBIDDEN, message, entityId ? { entityId } : undefined); From 628773df17912d3b15441b690c24cb55c2667c34 Mon Sep 17 00:00:00 2001 From: Alvin Sebastian Date: Mon, 24 Aug 2026 01:21:44 +0000 Subject: [PATCH 4/4] fix: replace calls to reply.forbidden with createForbiddenError helper --- src/routes/crate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/crate.ts b/src/routes/crate.ts index 262282a..6fa648d 100644 --- a/src/routes/crate.ts +++ b/src/routes/crate.ts @@ -5,7 +5,7 @@ import { z } from 'zod/v4'; import type { PrismaClient } from '../generated/prisma/client.js'; import type { FileMetadata, RoCrateHandler } from '../types/fileHandlers.js'; import type { AccessTransformer } from '../types/transformers.js'; -import { createInternalError, createNotFoundError } from '../utils/errors.js'; +import { createForbiddenError, createInternalError, createNotFoundError } from '../utils/errors.js'; import { setFileHeaders } from '../utils/headers.js'; const paramsSchema = z.object({ @@ -45,7 +45,7 @@ const crate: FastifyPluginAsync = async (fastify, opts) => { rootCollection: { id: entity.rootCollection || '', name: '' }, }; const authorisedEntity = await accessTransformer(standardEntity, { request, fastify }); - if (!authorisedEntity.access.metadata) return reply.forbidden('Access to this resource is restricted'); + if (!authorisedEntity.access.metadata) return createForbiddenError('Access to this resource is restricted'); const metadata: FileMetadata | false = await roCrateHandler.head(entity, { request, fastify }); @@ -90,7 +90,7 @@ const crate: FastifyPluginAsync = async (fastify, opts) => { rootCollection: { id: entity.rootCollection || '', name: '' }, }; const authorisedEntity = await accessTransformer(standardEntity, { request, fastify }); - if (!authorisedEntity.access.metadata) return reply.forbidden('Access to this resource is restricted'); + if (!authorisedEntity.access.metadata) return createForbiddenError('Access to this resource is restricted'); const result = await roCrateHandler.get(entity, { request, fastify });