diff --git a/src/app.ts b/src/app.ts index f511176..50d1889 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,15 +132,16 @@ 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, fileHandler }); - fastify.register(crate, { prisma, roCrateHandler }); + fastify.register(files, { prisma, fileAccessTransformer, fileTransformers, resolveValidLicenses }); + fastify.register(file, { prisma, fileAccessTransformer, fileHandler }); + fastify.register(crate, { prisma, accessTransformer, roCrateHandler }); fastify.register(search, { prisma, opensearch, accessTransformer, + resolveValidLicenses, entityTransformers, queryBuilderClass, queryBuilderOptions, 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/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..6fa648d 100644 --- a/src/routes/crate.ts +++ b/src/routes/crate.ts @@ -4,7 +4,8 @@ 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 { createInternalError, createNotFoundError } from '../utils/errors.js'; +import type { AccessTransformer } from '../types/transformers.js'; +import { createForbiddenError, createInternalError, createNotFoundError } from '../utils/errors.js'; import { setFileHeaders } from '../utils/headers.js'; const paramsSchema = z.object({ @@ -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 createForbiddenError('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 createForbiddenError('Access to this resource is restricted'); + const result = await roCrateHandler.get(entity, { request, fastify }); if (!result) { 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/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/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 2ad7f38..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,6 +49,8 @@ const entity: FastifyPluginAsync = async (fastify, opts) => }; const authorisedEntity = await accessTransformer(standardEntity, { request, fastify }); + 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 de41493..58ead5f 100644 --- a/src/routes/file.test.ts +++ b/src/routes/file.test.ts @@ -1,7 +1,14 @@ 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'; import fileRoute from './file.js'; @@ -18,7 +25,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(); }); @@ -34,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', () => { @@ -393,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 dc0a1f1..f4a7ab9 100644 --- a/src/routes/file.ts +++ b/src/routes/file.ts @@ -3,8 +3,10 @@ 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 { createInternalError, createNotFoundError } from '../utils/errors.js'; +import type { FileAccessTransformer } from '../types/transformers.js'; +import { createForbiddenError, createInternalError, createNotFoundError } from '../utils/errors.js'; import { setFileHeaders } from '../utils/headers.js'; const paramsSchema = z.object({ @@ -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,25 @@ 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.code(403).send(createForbiddenError('Access to this resource is restricted', id)); + } + const metadata: FileMetadata | false = await fileHandler.head(file, { request, fastify }); if (!metadata) { @@ -76,12 +92,25 @@ 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.code(403).send(createForbiddenError('Access to this resource is restricted', id)); + } + const result = await fileHandler.get(file, { request, fastify }); if (!result) { 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 f524690..c9c2720 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -2,8 +2,8 @@ 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 type { FileAccessTransformer, FileTransformer } from '../types/transformers.js'; +import { baseFileTransformer, resolveEntityReferences } from '../transformers/default.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, @@ -48,18 +59,28 @@ 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) { + for (const transformer of fileTransformers || []) { result = await transformer(result, { request, fastify }); } 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', 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/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; 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);