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
14 changes: 10 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 @@ -71,6 +72,8 @@ const setupValidation = (fastify: FastifyInstance) => {
});
};

export type LicenseResolver = (opt: TransformerContext) => Promise<string[]>;

export type Options = {
prisma: PrismaClient;
opensearch: Client;
Expand All @@ -83,6 +86,7 @@ export type Options = {
fileTransformers?: FileTransformer[];
fileHandler: FileHandler;
roCrateHandler: RoCrateHandler;
resolveValidLicenses?: LicenseResolver;
};
const app: FastifyPluginAsync<Options> = async (fastify, options) => {
const {
Expand All @@ -97,6 +101,7 @@ const app: FastifyPluginAsync<Options> = async (fastify, options) => {
fileTransformers,
fileHandler,
roCrateHandler,
resolveValidLicenses,
} = options;

if (!prisma) {
Expand Down Expand Up @@ -129,15 +134,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",
},
}
`;
75 changes: 73 additions & 2 deletions src/routes/crate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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, RestrictedAccessTransformer } 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 Expand Up @@ -421,3 +426,69 @@ describe('Crate Route', () => {
});
});
});

describe('Crate Route Restricted', () => {
const mockRoCrateHandler: RoCrateHandler = {
get: vi.fn(),
head: vi.fn(),
};

beforeEach(async () => {
await fastifyBefore();
await fastify.register(crateRoute, {
prisma,
accessTransformer: RestrictedAccessTransformer,
roCrateHandler: mockRoCrateHandler,
});
});

afterEach(async () => {
await fastifyAfter();
});

const mockFileEntity = {
id: 'http://example.com/entity/file.wav',
name: 'test.wav',
description: 'A test file',
entityType: 'http://schema.org/MediaObject',
memberOf: 'http://example.com/collection',
rootCollection: 'http://example.com/collection',
metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/',
createdAt: new Date(),
updatedAt: new Date(),
meta: {},
};

describe('HEAD /entity/:id', () => {
it('should return 403', async () => {
prisma.entity.findUnique.mockResolvedValue(mockFileEntity);

const response = await fastify.inject({
method: 'HEAD',
url: `/entity/${encodeURIComponent('http://example.com/entity/file.wav')}/rocrate`,
});
const body = JSON.parse(response.body) as { error: { code: string; message: string } };

expect(response.statusCode).toBe(403);
expect(body.error.code).toBe('FORBIDDEN');
expect(mockRoCrateHandler.head).not.toHaveBeenCalled();
});
});

describe('GET /entity/:id', () => {
it('should return 403', async () => {
prisma.entity.findUnique.mockResolvedValue(mockFileEntity);

const response = await fastify.inject({
method: 'GET',
url: `/entity/${encodeURIComponent('http://example.com/entity/file.wav')}/rocrate`,
});
const body = JSON.parse(response.body) as { error: { code: string; message: string } };

expect(response.statusCode).toBe(403);
expect(body.error.code).toBe('FORBIDDEN');
expect(mockRoCrateHandler.get).not.toHaveBeenCalled();
});
});
});
25 changes: 22 additions & 3 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,9 +39,17 @@ const crate: FastifyPluginAsync<CrateRouteOptions> = async (fastify, opts) => {
if (!entity) {
return reply.code(404).send(createNotFoundError('The requested entity was not found', id));
}
const standardEntity = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to use the refMap approach here as we do elsewhere?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To keep it simple, as they are not really used yet, and you need to recompute the result in the resolveEntityReferences function. We only need to check the access there by calling the accessTransformer.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But then we are passing invalid data into accessTransformer. What if they wanted to restrict access based on the name of memberOf?

...entity,
memberOf: { id: entity.memberOf || '', name: '' },
rootCollection: { id: entity.rootCollection || '', name: '' },
};
const authorisedEntity = await accessTransformer(standardEntity, { request, fastify });
if (!authorisedEntity.access.metadata) {
return reply.code(403).send(createForbiddenError('Access to this resource is restricted'));
}

const metadata: FileMetadata | false = await roCrateHandler.head(entity, { request, fastify });

if (!metadata) {
return reply.code(404).send(createNotFoundError('The requested RO-Crate metadata was not found', id));
}
Expand Down Expand Up @@ -75,6 +85,15 @@ const crate: FastifyPluginAsync<CrateRouteOptions> = async (fastify, opts) => {
return reply.code(404).send(createNotFoundError('The requested entity was not found', id));
}

const standardEntity = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as above

...entity,
memberOf: { id: entity.memberOf || '', name: '' },
rootCollection: { id: entity.rootCollection || '', name: '' },
};
const authorisedEntity = await accessTransformer(standardEntity, { request, fastify });
if (!authorisedEntity.access.metadata)
return reply.code(403).send(createForbiddenError('Access to this resource is restricted'));

const result = await roCrateHandler.get(entity, { request, fastify });

if (!result) {
Expand Down
61 changes: 61 additions & 0 deletions src/routes/entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,64 @@ describe('Entities Route', () => {
});
});
});

describe('Entities Route with License Filtering', () => {
let hasLicense = true;
async function resolveValidLicenses() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type this so we don't need the ts-expect-error below

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @ts-expect-error is required to test if the implementation of resolveValidLicenses function returns undefined or null, see below.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining the function as

  async function resolveValidLicenses() {
    if (hasLicense) {
      return ['https://creativecommons.org/licenses/by/4.0/'];
    }

    return [];
  }

Should remove the need for the error comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would fail the coverage test (branch section)

if (hasLicense) {
return ['https://creativecommons.org/licenses/by/4.0/'];
}
}
beforeEach(async () => {
await fastifyBefore();
await fastify.register(entitiesRoute, {
prisma,
accessTransformer: AllPublicAccessTransformer,
// @ts-expect-error
resolveValidLicenses,
});
});

afterEach(async () => {
await fastifyAfter();
});

describe('GET /entities', () => {
it('should filter by metadataLicenseId', async () => {
prisma.entity.findMany.mockResolvedValue([]);
prisma.entity.count.mockResolvedValue(0);
hasLicense = true;
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,
});
});
it('should return nothing without any valid license', async () => {
prisma.entity.findMany.mockResolvedValue([]);
prisma.entity.count.mockResolvedValue(0);
hasLicense = false;
const response = await fastify.inject({
method: 'GET',
url: '/entities',
});

expect(response.statusCode).toBe(200);
expect(prisma.entity.findMany).toHaveBeenCalledWith({
where: { metadataLicenseId: { in: [] } },
include: { file: { select: { id: true } } },
orderBy: { id: 'asc' },
skip: 0,
take: 100,
});
});
});
});
11 changes: 9 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,12 @@ const entities: FastifyPluginAsync<EntitiesRouteOptions> = async (fastify, opts)
};
}

if (resolveValidLicenses) {
where.metadataLicenseId = {
in: (await resolveValidLicenses({ request, fastify })) || [],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the || [] shouldn't be necessary. We should require resolveValidLicenses to always return an array.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is just an defensive approach as the library can be used as JS library and it is better to provide a sane dafault.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not convinced. This means that if we are provided with a function that has a bug i.e. it has a logic error and doesn't return anything, then we return unexpected data instead.

If you want to be defensive, then throw an error if the function does the wrong thing.

It is up to the caller to honour the contract. If they don't, that's a bug in their code that we are hiding from them.

};
}

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();
});
});
});
5 changes: 4 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,9 @@ 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