From a183f7f9172bf235d419fe6d6d6c64408cc6f4a4 Mon Sep 17 00:00:00 2001 From: JR40159 <126243293+JR40159@users.noreply.github.com> Date: Fri, 20 Sep 2024 16:46:08 +0000 Subject: [PATCH 1/4] Prevent access requests and model cards being created using hidden schemas --- backend/src/routes/v2/schema/getSchema.ts | 8 +++----- backend/src/routes/v2/schema/getSchemas.ts | 6 +++--- backend/src/services/accessRequest.ts | 9 ++++++--- backend/src/services/model.ts | 11 +++++++---- backend/src/services/modelCardExport.ts | 4 ++-- backend/src/services/schema.ts | 13 ++++++------- backend/test/services/modelCardExport.spec.ts | 6 +++--- backend/test/services/schema.spec.ts | 8 ++++---- frontend/actions/schema.ts | 4 ++-- .../pages/model/[modelId]/access-request/schema.tsx | 2 +- frontend/src/schemas/SchemaList.tsx | 2 +- frontend/src/schemas/SchemaSelect.tsx | 1 + 12 files changed, 39 insertions(+), 35 deletions(-) diff --git a/backend/src/routes/v2/schema/getSchema.ts b/backend/src/routes/v2/schema/getSchema.ts index f72735d42..f96e9e13f 100644 --- a/backend/src/routes/v2/schema/getSchema.ts +++ b/backend/src/routes/v2/schema/getSchema.ts @@ -5,15 +5,13 @@ import { z } from 'zod' import { AuditInfo } from '../../../connectors/audit/Base.js' import audit from '../../../connectors/audit/index.js' import { SchemaInterface } from '../../../models/Schema.js' -import { findSchemaById } from '../../../services/schema.js' +import { getSchemaById } from '../../../services/schema.js' import { registerPath, schemaInterfaceSchema } from '../../../services/specification.js' import { parse } from '../../../utils/validate.js' export const getSchemaSchema = z.object({ params: z.object({ - schemaId: z.string({ - required_error: 'Must specify schema id as URL parameter', - }), + schemaId: z.string(), }), }) @@ -47,7 +45,7 @@ export const getSchema = [ req.audit = AuditInfo.ViewSchema const { params } = parse(req, getSchemaSchema) - const schema = await findSchemaById(params.schemaId) + const schema = await getSchemaById(params.schemaId) await audit.onViewSchema(req, schema) return res.json({ diff --git a/backend/src/routes/v2/schema/getSchemas.ts b/backend/src/routes/v2/schema/getSchemas.ts index fa495236d..3708f464c 100644 --- a/backend/src/routes/v2/schema/getSchemas.ts +++ b/backend/src/routes/v2/schema/getSchemas.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import { AuditInfo } from '../../../connectors/audit/Base.js' import audit from '../../../connectors/audit/index.js' import { SchemaInterface } from '../../../models/Schema.js' -import { findSchemasByKind } from '../../../services/schema.js' +import { searchSchemas } from '../../../services/schema.js' import { registerPath, schemaInterfaceSchema } from '../../../services/specification.js' import { SchemaKind } from '../../../types/enums.js' import { parse, strictCoerceBoolean } from '../../../utils/validate.js' @@ -13,7 +13,7 @@ import { parse, strictCoerceBoolean } from '../../../utils/validate.js' export const getSchemasSchema = z.object({ query: z.object({ kind: z.nativeEnum(SchemaKind).optional(), - includeHidden: strictCoerceBoolean(z.boolean().optional().default(false)), + hidden: strictCoerceBoolean(z.boolean().optional()), }), }) @@ -47,7 +47,7 @@ export const getSchemas = [ req.audit = AuditInfo.SearchSchemas const { query } = parse(req, getSchemasSchema) - const schemas = await findSchemasByKind(query.kind, query.includeHidden) + const schemas = await searchSchemas(query.kind, query.hidden) await audit.onSearchSchemas(req, schemas) return res.json({ diff --git a/backend/src/services/accessRequest.ts b/backend/src/services/accessRequest.ts index bc3d6b3b6..58ea9786c 100644 --- a/backend/src/services/accessRequest.ts +++ b/backend/src/services/accessRequest.ts @@ -16,7 +16,7 @@ import { convertStringToId } from '../utils/id.js' import log from './log.js' import { getModelById } from './model.js' import { createAccessRequestReviews } from './review.js' -import { findSchemaById } from './schema.js' +import { getSchemaById } from './schema.js' import { sendWebhooks } from './webhook.js' export type CreateAccessRequestParams = Pick @@ -29,7 +29,10 @@ export async function createAccessRequest( const model = await getModelById(user, modelId) // Ensure that the AR meets the schema - const schema = await findSchemaById(accessRequestInfo.schemaId) + const schema = await getSchemaById(accessRequestInfo.schemaId) + if (schema.hidden) { + throw BadReq('Cannot create new Access Request using a hidden schema.', { schemaId: accessRequestInfo.schemaId }) + } try { new Validator().validate(accessRequestInfo.metadata, schema.jsonSchema, { throwAll: true, required: true }) } catch (error) { @@ -128,7 +131,7 @@ export async function updateAccessRequest( } // Ensure that the AR meets the schema - const schema = await findSchemaById(accessRequest.schemaId, true) + const schema = await getSchemaById(accessRequest.schemaId) try { new Validator().validate(accessRequest.metadata, schema.jsonSchema, { throwAll: true, required: true }) } catch (error) { diff --git a/backend/src/services/model.ts b/backend/src/services/model.ts index 01960eea7..1ae37f034 100644 --- a/backend/src/services/model.ts +++ b/backend/src/services/model.ts @@ -16,7 +16,7 @@ import { isValidatorResultError } from '../types/ValidatorResultError.js' import { toEntity } from '../utils/entity.js' import { BadReq, Forbidden, InternalError, NotFound } from '../utils/error.js' import { convertStringToId } from '../utils/id.js' -import { findSchemaById } from './schema.js' +import { getSchemaById } from './schema.js' export function checkModelRestriction(model: ModelInterface) { if (model.settings.mirror.sourceModelId) { @@ -286,7 +286,7 @@ export async function updateModelCard( throw BadReq(`This model must first be instantiated before it can be `, { modelId }) } - const schema = await findSchemaById(model.card.schemaId, true) + const schema = await getSchemaById(model.card.schemaId) try { new Validator().validate(metadata, schema.jsonSchema, { throwAll: true, required: true }) } catch (error) { @@ -347,7 +347,10 @@ export async function createModelCardFromSchema( } // Ensure schema exists - await findSchemaById(schemaId) + const schema = await getSchemaById(schemaId) + if (schema.hidden) { + throw BadReq('Cannot create new Model Card using a hidden schema.', { schemaId }) + } const revision = await _setModelCard(user, modelId, schemaId, 1, {}) return revision @@ -397,7 +400,7 @@ export async function saveImportedModelCard(modelCard: ModelCardRevisionInterfac }) } - const schema = await findSchemaById(modelCard.schemaId, true) + const schema = await getSchemaById(modelCard.schemaId) try { new Validator().validate(modelCard.metadata, schema.jsonSchema, { throwAll: true, required: true }) } catch (error) { diff --git a/backend/src/services/modelCardExport.ts b/backend/src/services/modelCardExport.ts index 36a7a7a4e..711c5ba30 100644 --- a/backend/src/services/modelCardExport.ts +++ b/backend/src/services/modelCardExport.ts @@ -4,7 +4,7 @@ import showdown from 'showdown' import { UserInterface } from '../models/User.js' import { GetModelCardVersionOptionsKeys } from '../types/enums.js' import { getModelById, getModelCard } from './model.js' -import { findSchemaById } from './schema.js' +import { getSchemaById } from './schema.js' type Common = { title: string @@ -48,7 +48,7 @@ export async function renderToMarkdown( throw new Error('Could not find specified model card') } - const schema = await findSchemaById(card.schemaId, true) + const schema = await getSchemaById(card.schemaId) if (!schema) { throw new Error('Trying to export model with no corresponding card') } diff --git a/backend/src/services/schema.ts b/backend/src/services/schema.ts index 07bfaa38e..4ff196b98 100644 --- a/backend/src/services/schema.ts +++ b/backend/src/services/schema.ts @@ -17,18 +17,17 @@ export interface DefaultSchema { jsonSchema: JsonSchema } -export async function findSchemasByKind(kind?: SchemaKindKeys, includeHidden = false): Promise { - const baseSchemas = await Schema.find({ +export async function searchSchemas(kind?: SchemaKindKeys, hidden?: boolean): Promise { + const schemas = await Schema.find({ ...(kind && { kind }), - ...(!includeHidden && { hidden: false }), + ...(hidden != undefined && { hidden }), }).sort({ createdAt: -1 }) - return baseSchemas + return schemas } -export async function findSchemaById(schemaId: string, includeHidden = false) { +export async function getSchemaById(schemaId: string) { const schema = await Schema.findOne({ id: schemaId, - ...(!includeHidden && { hidden: false }), }) if (!schema) { @@ -86,7 +85,7 @@ export async function createSchema(user: UserInterface, schema: Partial> export async function updateSchema(user: UserInterface, schemaId: string, diff: UpdateSchemaParams) { - const schema = await findSchemaById(schemaId, true) + const schema = await getSchemaById(schemaId) const auth = await authorisation.schema(user, schema, SchemaAction.Update) if (!auth.success) { diff --git a/backend/test/services/modelCardExport.spec.ts b/backend/test/services/modelCardExport.spec.ts index 23f87992a..3c12bc48c 100644 --- a/backend/test/services/modelCardExport.spec.ts +++ b/backend/test/services/modelCardExport.spec.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import { getModelById, getModelCard } from '../../src/services/model.js' import { renderToHtml, renderToMarkdown } from '../../src/services/modelCardExport.js' -import { findSchemaById } from '../../src/services/schema.js' +import { getSchemaById } from '../../src/services/schema.js' vi.mock('../../src/services/model.js') vi.mock('../../src/services/schema.js') @@ -18,7 +18,7 @@ describe('services > export', () => { beforeEach(() => { vi.mocked(getModelById).mockResolvedValue(mockModel as any) vi.mocked(getModelCard).mockResolvedValue(mockCard as any) - vi.mocked(findSchemaById).mockResolvedValue(mockSchema as any) + vi.mocked(getSchemaById).mockResolvedValue(mockSchema as any) }) test('renderToMarkdown > should throw error if model has no card', async () => { @@ -38,7 +38,7 @@ describe('services > export', () => { }) test('renderToMarkdown > should throw error if schema is not found', async () => { - vi.mocked(findSchemaById).mockResolvedValueOnce(undefined as any) + vi.mocked(getSchemaById).mockResolvedValueOnce(undefined as any) await expect(renderToMarkdown(mockUser, mockModelId, mockVersion)).rejects.toThrow( 'Trying to export model with no corresponding card', diff --git a/backend/test/services/schema.spec.ts b/backend/test/services/schema.spec.ts index 43ecc2911..9f17eaf47 100644 --- a/backend/test/services/schema.spec.ts +++ b/backend/test/services/schema.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from 'vitest' import authorisation from '../../src/connectors/authorisation/index.js' import { UserInterface } from '../../src/models/User.js' -import { createSchema, findSchemaById, findSchemasByKind } from '../../src/services/schema.js' +import { createSchema, getSchemaById, searchSchemas } from '../../src/services/schema.js' import { testModelSchema } from '../testUtils/testModels.js' vi.mock('../../src/connectors/authorisation/index.js') @@ -43,7 +43,7 @@ describe('services > schema', () => { const testUser = { dn: 'user' } as UserInterface test('that all schemas can be retrieved', async () => { - const result = await findSchemasByKind('model') + const result = await searchSchemas('model') expect(result).toEqual(['schema-1', 'schema-2']) }) @@ -93,11 +93,11 @@ describe('services > schema', () => { test('that a schema can be retrieved by ID', async () => { mockSchema.findOne.mockResolvedValueOnce(testModelSchema) - const result = await findSchemaById(testModelSchema.id) + const result = await getSchemaById(testModelSchema.id) expect(result).toEqual(testModelSchema) }) test('that a schema cannot be retrieved by ID when schema does not exist', async () => { - expect(() => findSchemaById(testModelSchema.id)).rejects.toThrowError(/^The requested schema was not found/) + expect(() => getSchemaById(testModelSchema.id)).rejects.toThrowError(/^The requested schema was not found/) }) }) diff --git a/frontend/actions/schema.ts b/frontend/actions/schema.ts index c7872797e..d6f92e9ac 100644 --- a/frontend/actions/schema.ts +++ b/frontend/actions/schema.ts @@ -14,10 +14,10 @@ export interface PostSchemaParams { jsonSchema: any } -export function useGetSchemas(kind: SchemaKindKeys, includeHidden = false) { +export function useGetSchemas(kind: SchemaKindKeys, hidden?: boolean) { const queryParams = { kind, - includeHidden, + ...(hidden != undefined && { hidden }), } const { data, isLoading, error, mutate } = useSWR< diff --git a/frontend/pages/model/[modelId]/access-request/schema.tsx b/frontend/pages/model/[modelId]/access-request/schema.tsx index d18882c80..346f36f8a 100644 --- a/frontend/pages/model/[modelId]/access-request/schema.tsx +++ b/frontend/pages/model/[modelId]/access-request/schema.tsx @@ -28,7 +28,7 @@ import { SchemaInterface, SchemaKind } from 'types/types' export default function AccessRequestSchema() { const router = useRouter() const { modelId }: { modelId?: string } = router.query - const { schemas, isSchemasLoading, isSchemasError } = useGetSchemas(SchemaKind.ACCESS_REQUEST) + const { schemas, isSchemasLoading, isSchemasError } = useGetSchemas(SchemaKind.ACCESS_REQUEST, false) const [loading, setLoading] = useState(false) diff --git a/frontend/src/schemas/SchemaList.tsx b/frontend/src/schemas/SchemaList.tsx index caeeba6ae..d3a570691 100644 --- a/frontend/src/schemas/SchemaList.tsx +++ b/frontend/src/schemas/SchemaList.tsx @@ -38,7 +38,7 @@ interface ObjectToDelete { } export default function SchemaList({ schemaKind }: SchemaDisplayProps) { - const { schemas, isSchemasLoading, isSchemasError, mutateSchemas } = useGetSchemas(schemaKind, true) + const { schemas, isSchemasLoading, isSchemasError, mutateSchemas } = useGetSchemas(schemaKind, false) const { reviews, isReviewsLoading, isReviewsError } = useGetReviewRequestsForUser() const theme = useTheme() diff --git a/frontend/src/schemas/SchemaSelect.tsx b/frontend/src/schemas/SchemaSelect.tsx index 0c879cd1b..5c536c4b4 100644 --- a/frontend/src/schemas/SchemaSelect.tsx +++ b/frontend/src/schemas/SchemaSelect.tsx @@ -35,6 +35,7 @@ export default function SchemaSelect({ entry }: SchemaSelectProps) { const [errorMessage, setErrorMessage] = useState('') const { schemas, isSchemasLoading, isSchemasError } = useGetSchemas( entry.kind === EntryKind.MODEL ? SchemaKind.MODEL : SchemaKind.DATA_CARD, + false, ) const { currentUser, isCurrentUserLoading, isCurrentUserError } = useGetCurrentUser() const { mutateModel: mutateEntry } = useGetModel(entry.id, entry.kind) From 903e59533514bad934aba89f5333d11e9eb47a57 Mon Sep 17 00:00:00 2001 From: JR40159 <126243293+JR40159@users.noreply.github.com> Date: Fri, 20 Sep 2024 17:26:17 +0000 Subject: [PATCH 2/4] Fix error message and list hidden schemas on admin schema page --- backend/src/services/model.ts | 2 +- frontend/src/schemas/SchemaList.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/services/model.ts b/backend/src/services/model.ts index 1ae37f034..7841505c1 100644 --- a/backend/src/services/model.ts +++ b/backend/src/services/model.ts @@ -349,7 +349,7 @@ export async function createModelCardFromSchema( // Ensure schema exists const schema = await getSchemaById(schemaId) if (schema.hidden) { - throw BadReq('Cannot create new Model Card using a hidden schema.', { schemaId }) + throw BadReq('Cannot create a new Entity using a hidden schema.', { schemaId, kind: schema.kind }) } const revision = await _setModelCard(user, modelId, schemaId, 1, {}) diff --git a/frontend/src/schemas/SchemaList.tsx b/frontend/src/schemas/SchemaList.tsx index d3a570691..869b2d062 100644 --- a/frontend/src/schemas/SchemaList.tsx +++ b/frontend/src/schemas/SchemaList.tsx @@ -38,7 +38,7 @@ interface ObjectToDelete { } export default function SchemaList({ schemaKind }: SchemaDisplayProps) { - const { schemas, isSchemasLoading, isSchemasError, mutateSchemas } = useGetSchemas(schemaKind, false) + const { schemas, isSchemasLoading, isSchemasError, mutateSchemas } = useGetSchemas(schemaKind) const { reviews, isReviewsLoading, isReviewsError } = useGetReviewRequestsForUser() const theme = useTheme() From ad8d0646734a0f72671411852a6346c884b7c5ca Mon Sep 17 00:00:00 2001 From: JR40159 <126243293+JR40159@users.noreply.github.com> Date: Fri, 20 Sep 2024 17:40:54 +0000 Subject: [PATCH 3/4] Fix tests --- backend/test/routes/schema/getSchema.spec.ts | 8 ++++---- backend/test/routes/schema/getSchemas.spec.ts | 8 ++++---- backend/test/services/accessRequest.spec.ts | 6 +++--- backend/test/services/model.spec.ts | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/test/routes/schema/getSchema.spec.ts b/backend/test/routes/schema/getSchema.spec.ts index db74c63c2..89e473d0e 100644 --- a/backend/test/routes/schema/getSchema.spec.ts +++ b/backend/test/routes/schema/getSchema.spec.ts @@ -14,14 +14,14 @@ vi.mock('../../../src/connectors/authorisation/index.js') const mockSchemaService = vi.hoisted(() => { return { addDefaultSchemas: vi.fn(), - findSchemaById: vi.fn(), + getSchemaById: vi.fn(), } }) vi.mock('../../../src/services/schema.js', () => mockSchemaService) describe('routes > schema > getSchema', () => { test('returns the schema with the matching ID', async () => { - mockSchemaService.findSchemaById.mockReturnValueOnce(testModelSchema) + mockSchemaService.getSchemaById.mockReturnValueOnce(testModelSchema) const res = await testGet(`/api/v2/schema/${testModelSchema.id}`) expect(res.statusCode).toBe(200) @@ -29,7 +29,7 @@ describe('routes > schema > getSchema', () => { }) test('audit > expected call', async () => { - mockSchemaService.findSchemaById.mockReturnValueOnce(testModelSchema) + mockSchemaService.getSchemaById.mockReturnValueOnce(testModelSchema) const res = await testGet(`/api/v2/schema/${testModelSchema.id}`) expect(res.statusCode).toBe(200) @@ -38,7 +38,7 @@ describe('routes > schema > getSchema', () => { }) test('returns the schema with the matching ID', async () => { - mockSchemaService.findSchemaById.mockRejectedValueOnce(NotFound('Schema not found.')) + mockSchemaService.getSchemaById.mockRejectedValueOnce(NotFound('Schema not found.')) const res = await testGet(`/api/v2/schema/does-not-exist`) expect(res.statusCode).toBe(404) diff --git a/backend/test/routes/schema/getSchemas.spec.ts b/backend/test/routes/schema/getSchemas.spec.ts index f90ebc599..0dca0e02c 100644 --- a/backend/test/routes/schema/getSchemas.spec.ts +++ b/backend/test/routes/schema/getSchemas.spec.ts @@ -13,7 +13,7 @@ vi.mock('../../../src/connectors/authorisation/index.js') const mockSchemaService = vi.hoisted(() => { return { addDefaultSchemas: vi.fn(), - findSchemasByKind: vi.fn(() => [testDeploymentSchema, testModelSchema]), + searchSchemas: vi.fn(() => [testDeploymentSchema, testModelSchema]), } }) vi.mock('../../../src/services/schema.js', () => mockSchemaService) @@ -35,7 +35,7 @@ describe('routes > schema > getSchemas', () => { }) test('returns only model schemas with the model parameter', async () => { - mockSchemaService.findSchemasByKind.mockReturnValueOnce([testModelSchema]) + mockSchemaService.searchSchemas.mockReturnValueOnce([testModelSchema]) const res = await testGet(`/api/v2/schemas?kind=model`) expect(res.statusCode).toBe(200) @@ -43,7 +43,7 @@ describe('routes > schema > getSchemas', () => { }) test('returns only deployment schemas with the accessRequest parameter', async () => { - mockSchemaService.findSchemasByKind.mockReturnValueOnce([testDeploymentSchema]) + mockSchemaService.searchSchemas.mockReturnValueOnce([testDeploymentSchema]) const res = await testGet(`/api/v2/schemas?kind=accessRequest`) expect(res.statusCode).toBe(200) @@ -53,7 +53,7 @@ describe('routes > schema > getSchemas', () => { test('rejects unknown query parameter', async () => { const res = await testGet(`/api/v2/schemas?kind=notValid`) - expect(mockSchemaService.findSchemasByKind).not.toBeCalled() + expect(mockSchemaService.searchSchemas).not.toBeCalled() expect(res.statusCode).toBe(400) expect(res.body).matchSnapshot() }) diff --git a/backend/test/services/accessRequest.spec.ts b/backend/test/services/accessRequest.spec.ts index ec0bc1ceb..cea2f8449 100644 --- a/backend/test/services/accessRequest.spec.ts +++ b/backend/test/services/accessRequest.spec.ts @@ -17,7 +17,7 @@ const modelMocks = vi.hoisted(() => ({ vi.mock('../../src/services/model.js', () => modelMocks) const schemaMocks = vi.hoisted(() => ({ - findSchemaById: vi.fn(), + getSchemaById: vi.fn(), })) vi.mock('../../src/services/schema.js', () => schemaMocks) @@ -62,7 +62,7 @@ const accessRequest = { describe('services > accessRequest', () => { test('createAccessRequest > simple', async () => { modelMocks.getModelById.mockResolvedValue(undefined) - schemaMocks.findSchemaById.mockResolvedValue({ jsonSchema: {} }) + schemaMocks.getSchemaById.mockResolvedValue({ jsonSchema: {} }) await createAccessRequest({} as any, 'example-model', accessRequest) @@ -80,7 +80,7 @@ describe('services > accessRequest', () => { }) modelMocks.getModelById.mockResolvedValue(undefined) - schemaMocks.findSchemaById.mockResolvedValue({ jsonSchema: {} }) + schemaMocks.getSchemaById.mockResolvedValue({ jsonSchema: {} }) expect(() => createAccessRequest({} as any, 'example-model', accessRequest)).rejects.toThrowError( /^You do not have permission/, diff --git a/backend/test/services/model.spec.ts b/backend/test/services/model.spec.ts index 792243f3d..caeed66d6 100644 --- a/backend/test/services/model.spec.ts +++ b/backend/test/services/model.spec.ts @@ -22,7 +22,7 @@ import { vi.mock('../../src/connectors/authorisation/index.js') const schemaMock = vi.hoisted(() => ({ - findSchemaById: vi.fn(() => ({ jsonschema: {} })), + getSchemaById: vi.fn(() => ({ jsonschema: {} })), })) vi.mock('../../src/services/schema.js', async () => schemaMock) From 1051331358d7c2df4925f44504ac33370a2d3ec5 Mon Sep 17 00:00:00 2001 From: JR40159 <126243293+JR40159@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:46:12 +0000 Subject: [PATCH 4/4] Update error message --- backend/src/services/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/model.ts b/backend/src/services/model.ts index 7841505c1..df4f6b659 100644 --- a/backend/src/services/model.ts +++ b/backend/src/services/model.ts @@ -349,7 +349,7 @@ export async function createModelCardFromSchema( // Ensure schema exists const schema = await getSchemaById(schemaId) if (schema.hidden) { - throw BadReq('Cannot create a new Entity using a hidden schema.', { schemaId, kind: schema.kind }) + throw BadReq('Cannot create a new Card using a hidden schema.', { schemaId, kind: schema.kind }) } const revision = await _setModelCard(user, modelId, schemaId, 1, {})