Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,6 +84,7 @@ export type Options = {
fileTransformers?: FileTransformer[];
fileHandler: FileHandler;
roCrateHandler: RoCrateHandler;
resolveValidLicenses?: (opt: TransformerContext) => Promise<string[]>;
};
const app: FastifyPluginAsync<Options> = async (fastify, options) => {
const {
Expand All @@ -97,6 +99,7 @@ const app: FastifyPluginAsync<Options> = async (fastify, options) => {
fileTransformers,
fileHandler,
roCrateHandler,
resolveValidLicenses,
} = options;

if (!prisma) {
Expand Down Expand Up @@ -129,15 +132,16 @@ const app: FastifyPluginAsync<Options> = 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,
Expand Down
12 changes: 12 additions & 0 deletions src/routes/__snapshots__/entity.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
`;
7 changes: 6 additions & 1 deletion src/routes/crate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
});

Expand Down
21 changes: 19 additions & 2 deletions src/routes/crate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -13,11 +14,12 @@ const paramsSchema = z.object({

type CrateRouteOptions = {
prisma: PrismaClient;
accessTransformer: AccessTransformer;
roCrateHandler: RoCrateHandler;
};

const crate: FastifyPluginAsync<CrateRouteOptions> = async (fastify, opts) => {
const { prisma, roCrateHandler } = opts;
const { prisma, accessTransformer, roCrateHandler } = opts;

fastify.withTypeProvider<ZodTypeProvider>().head(
'/entity/:id/rocrate',
Expand All @@ -37,6 +39,13 @@ const crate: FastifyPluginAsync<CrateRouteOptions> = 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 });

Expand Down Expand Up @@ -75,6 +84,14 @@ const crate: FastifyPluginAsync<CrateRouteOptions> = 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) {
Expand Down
39 changes: 39 additions & 0 deletions src/routes/entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
});
14 changes: 12 additions & 2 deletions src/routes/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -27,10 +27,11 @@ type EntitiesRouteOptions = {
prisma: PrismaClient;
accessTransformer: AccessTransformer;
entityTransformers?: EntityTransformer[];
resolveValidLicenses?: (opt: TransformerContext) => Promise<string[]>;
};

const entities: FastifyPluginAsync<EntitiesRouteOptions> = async (fastify, opts) => {
const { prisma, accessTransformer, entityTransformers = [] } = opts;
const { prisma, accessTransformer, entityTransformers = [], resolveValidLicenses } = opts;
fastify.withTypeProvider<ZodTypeProvider>().get(
'/entities',
{
Expand All @@ -54,6 +55,15 @@ const entities: FastifyPluginAsync<EntitiesRouteOptions> = 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,
Expand Down
42 changes: 41 additions & 1 deletion src/routes/entity.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
});
4 changes: 3 additions & 1 deletion src/routes/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -49,6 +49,8 @@ const entity: FastifyPluginAsync<EntityRouteOptions> = 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 });
Expand Down
Loading
Loading