Skip to content

Commit

Permalink
Merge branch 'main' into feature/download-file
Browse files Browse the repository at this point in the history
  • Loading branch information
a3957273 committed Nov 3, 2023
2 parents c2d497b + 0ef684d commit ebe81a4
Show file tree
Hide file tree
Showing 24 changed files with 608 additions and 61 deletions.
6 changes: 6 additions & 0 deletions backend/src/connectors/v2/authentication/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ import { ModelDoc } from '../../../models/v2/Model.js'
import { UserDoc } from '../../../models/v2/User.js'
import { User } from '../../../types/v2/types.js'

export const Roles = {
Admin: 'admin',
}
export type RoleKeys = (typeof Roles)[keyof typeof Roles]

export abstract class BaseAuthenticationConnector {
abstract getUserFromReq(req: Request): Promise<User>
abstract hasRole(user: UserDoc, role: RoleKeys): Promise<boolean>

abstract queryEntities(query: string): Promise<Array<{ kind: string; entities: Array<string> }>>
abstract getEntities(user: UserDoc): Promise<Array<string>>
Expand Down
9 changes: 8 additions & 1 deletion backend/src/connectors/v2/authentication/silly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request } from 'express'

import { UserDoc } from '../../../models/v2/User.js'
import { fromEntity, toEntity } from '../../../utils/v2/entity.js'
import { BaseAuthenticationConnector } from './Base.js'
import { BaseAuthenticationConnector, RoleKeys, Roles } from './Base.js'

const SillyEntityKind = {
User: 'user',
Expand All @@ -20,6 +20,13 @@ export class SillyAuthenticationConnector extends BaseAuthenticationConnector {
}
}

async hasRole(_user: UserDoc, role: RoleKeys) {
if (role === Roles.Admin) {
return true
}
return false
}

async queryEntities(_query: string) {
return [
{
Expand Down
9 changes: 8 additions & 1 deletion backend/src/connectors/v2/authorisation/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AccessRequestDoc } from '../../../models/v2/AccessRequest.js'
import { FileInterfaceDoc } from '../../../models/v2/File.js'
import { ModelDoc, ModelVisibility } from '../../../models/v2/Model.js'
import { ReleaseDoc } from '../../../models/v2/Release.js'
import { SchemaDoc } from '../../../models/v2/Schema.js'
import { UserDoc } from '../../../models/v2/User.js'
import { Access } from '../../../routes/v1/registryAuth.js'
import authentication from '../authentication/index.js'
Expand Down Expand Up @@ -29,7 +30,12 @@ export const AccessRequestAction = {
Update: 'update',
Delete: 'delete',
}
export type AccessRequestActionKeys = (typeof ReleaseAction)[keyof typeof ReleaseAction]
export type AccessRequestActionKeys = (typeof AccessRequestAction)[keyof typeof AccessRequestAction]

export const SchemaAction = {
Create: 'create',
}
export type SchemaActionKeys = (typeof SchemaAction)[keyof typeof SchemaAction]

export const FileAction = {
Download: 'download',
Expand All @@ -44,6 +50,7 @@ export type ImageActionKeys = (typeof ImageAction)[keyof typeof ImageAction]

export abstract class BaseAuthorisationConnector {
abstract userModelAction(user: UserDoc, model: ModelDoc, action: ModelActionKeys): Promise<boolean>
abstract userSchemaAction(user: UserDoc, Schema: SchemaDoc, action: SchemaActionKeys): Promise<boolean>
abstract userReleaseAction(
user: UserDoc,
model: ModelDoc,
Expand Down
7 changes: 7 additions & 0 deletions backend/src/connectors/v2/authorisation/silly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { AccessRequestDoc } from '../../../models/v2/AccessRequest.js'
import { FileInterfaceDoc } from '../../../models/v2/File.js'
import { ModelDoc } from '../../../models/v2/Model.js'
import { ReleaseDoc } from '../../../models/v2/Release.js'
import { SchemaDoc } from '../../../models/v2/Schema.js'
import { UserDoc } from '../../../models/v2/User.js'
import { Access } from '../../../routes/v1/registryAuth.js'
import { getAccessRequestsByModel } from '../../../services/v2/accessRequest.js'
import log from '../../../services/v2/log.js'
import { Roles } from '../authentication/Base.js'
import authentication from '../authentication/index.js'
import {
AccessRequestActionKeys,
Expand All @@ -16,6 +18,7 @@ import {
ImageActionKeys,
ModelActionKeys,
ReleaseActionKeys,
SchemaActionKeys,
} from './Base.js'

export class SillyAuthorisationConnector extends BaseAuthorisationConnector {
Expand Down Expand Up @@ -129,4 +132,8 @@ export class SillyAuthorisationConnector extends BaseAuthorisationConnector {

return true
}

async userSchemaAction(user: UserDoc, _schema: SchemaDoc, _action: SchemaActionKeys) {
return authentication.hasRole(user, Roles.Admin)
}
}
4 changes: 3 additions & 1 deletion backend/src/routes/v2/model/file/getDownloadFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ export const getDownloadFile = [
res.set('Content-Length', String(file.size))
// TODO: support ranges
// res.set('Accept-Ranges', 'bytes')
res.writeHead(200)

const stream = await downloadFile(req.user, fileId)

if (!stream.Body) {
throw InternalError('We were not able to retrieve the body of this file', { fileId })
}

res.writeHead(200)

// The AWS library doesn't seem to properly type 'Body' as being pipeable?
;(stream.Body as stream.Readable).pipe(res)
},
Expand Down
4 changes: 1 addition & 3 deletions backend/src/routes/v2/schema/postSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { SchemaInterface } from '../../../models/v2/Schema.js'
import { createSchema } from '../../../services/v2/schema.js'
import { registerPath, schemaInterfaceSchema } from '../../../services/v2/specification.js'
import { SchemaKind } from '../../../types/v2/enums.js'
import { ensureUserRole } from '../../../utils/user.js'
import { parse } from '../../../utils/v2/validate.js'

export const postSchemaSchema = z.object({
Expand Down Expand Up @@ -55,12 +54,11 @@ interface PostSchemaResponse {
}

export const postSchema = [
ensureUserRole('admin'),
bodyParser.json(),
async (req: Request, res: Response<PostSchemaResponse>) => {
const { body } = parse(req, postSchemaSchema)

const schema = await createSchema(body)
const schema = await createSchema(req.user, body)

return res.json({
schema,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/v2/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const getSpecification = [

res.json(
generator.generateDocument({
openapi: '3.1.0',
openapi: '3.0.0',
info: {
version: '2.0.0',
title: 'Bailo API',
Expand Down
30 changes: 28 additions & 2 deletions backend/src/services/v2/release.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,46 @@
import { ModelAction, ReleaseAction } from '../../connectors/v2/authorisation/Base.js'
import authorisation from '../../connectors/v2/authorisation/index.js'
import { ModelInterface } from '../../models/v2/Model.js'
import Release, { ReleaseDoc, ReleaseInterface } from '../../models/v2/Release.js'
import Release, { ImageRef, ReleaseDoc, ReleaseInterface } from '../../models/v2/Release.js'
import { UserDoc } from '../../models/v2/User.js'
import { asyncFilter } from '../../utils/v2/array.js'
import { Forbidden, NotFound } from '../../utils/v2/error.js'
import { BadReq, Forbidden, NotFound } from '../../utils/v2/error.js'
import { handleDuplicateKeys } from '../../utils/v2/mongo.js'
import log from './log.js'
import { getModelById } from './model.js'
import { listModelImages } from './registry.js'
import { createReleaseReviews } from './review.js'

export type CreateReleaseParams = Pick<
ReleaseInterface,
'modelId' | 'modelCardVersion' | 'semver' | 'notes' | 'minor' | 'draft' | 'fileIds' | 'images'
>
export async function createRelease(user: UserDoc, releaseParams: CreateReleaseParams) {
if (releaseParams.images) {
const registryImages = await listModelImages(user, releaseParams.modelId)

const initialValue: ImageRef[] = []
const missingImages = releaseParams.images.reduce((acc, releaseImage) => {
if (
!registryImages.some(
(registryImage) =>
releaseImage.name === registryImage.name &&
releaseImage.repository === registryImage.repository &&
registryImage.tags.includes(releaseImage.tag),
)
) {
acc.push(releaseImage)
}
return acc
}, initialValue)

if (missingImages.length > 0) {
throw BadReq('The following images do not exist in the registry.', {
missingImages,
})
}
}

const model = await getModelById(user, releaseParams.modelId)

const release = new Release({
Expand Down
74 changes: 42 additions & 32 deletions backend/src/services/v2/schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { SchemaAction } from '../../connectors/v2/authorisation/Base.js'
import authorisation from '../../connectors/v2/authorisation/index.js'
import Schema, { SchemaInterface } from '../../models/v2/Schema.js'
import { UserDoc } from '../../models/v2/User.js'
import accessRequestSchemaBeta from '../../scripts/example_schemas/minimal_access_request_schema_beta.json' assert { type: 'json' }
import modelSchemaBeta from '../../scripts/example_schemas/minimal_upload_schema_beta.json' assert { type: 'json' }
import { SchemaKind, SchemaKindKeys } from '../../types/v2/enums.js'
import { NotFound } from '../../utils/v2/error.js'
import { Forbidden, NotFound } from '../../utils/v2/error.js'
import { handleDuplicateKeys } from '../../utils/v2/mongo.js'

export async function findSchemasByKind(kind?: SchemaKindKeys): Promise<SchemaInterface[]> {
Expand All @@ -22,13 +25,20 @@ export async function findSchemaById(schemaId: string): Promise<SchemaInterface>
return schema
}

export async function createSchema(schema: Partial<SchemaInterface>, overwrite = false) {
export async function createSchema(user: UserDoc, schema: Partial<SchemaInterface>, overwrite = false) {
const schemaDoc = new Schema(schema)

if (!(await authorisation.userSchemaAction(user, schemaDoc, SchemaAction.Create))) {
throw Forbidden(`You do not have permission to create this schema.`, {
userDn: user.dn,
schemaId: schemaDoc.id,
})
}

if (overwrite) {
await Schema.deleteOne({ id: schema.id })
}

const schemaDoc = new Schema(schema)

try {
return await schemaDoc.save()
} catch (error) {
Expand All @@ -37,34 +47,34 @@ export async function createSchema(schema: Partial<SchemaInterface>, overwrite =
}
}

/**
* Use the mock data as defaults
* TODO - convert and use default schemas from V1
*/
export async function addDefaultSchemas() {
await createSchema(
{
name: 'Minimal Schema v10 Beta',
id: 'minimal-general-v10-beta',
description: 'This is a test beta schema',
jsonSchema: modelSchemaBeta,
kind: SchemaKind.Model,
active: true,
hidden: false,
},
true,
)
const uploadSchemaDoc = new Schema({
name: 'Minimal Schema v10 Beta',
id: 'minimal-general-v10-beta',
description: 'This is a test beta schema',
jsonSchema: modelSchemaBeta,
kind: SchemaKind.Model,
active: true,
hidden: false,
})

await createSchema(
{
name: 'Minimal Access REquestSchema v10 Beta',
id: 'minimal-access-request-general-v10-beta',
description: 'This is a test beta schema',
jsonSchema: accessRequestSchemaBeta,
kind: SchemaKind.AccessRequest,
active: true,
hidden: false,
},
true,
)
const accessSchemaDoc = new Schema({
name: 'Minimal Access RequestSchema v10 Beta',
id: 'minimal-access-request-general-v10-beta',
description: 'This is a test beta schema',
jsonSchema: accessRequestSchemaBeta,
kind: SchemaKind.AccessRequest,
active: true,
hidden: false,
})

await Schema.deleteMany({ $or: [{ id: uploadSchemaDoc.id }, { id: accessSchemaDoc.id }] })

try {
await uploadSchemaDoc.save()
await accessSchemaDoc.save()
} catch (error) {
handleDuplicateKeys(error)
throw error
}
}
36 changes: 36 additions & 0 deletions backend/test/routes/model/file/getDownloadFile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Readable } from 'stream'
import { describe, expect, test, vi } from 'vitest'

import { getDownloadFileSchema } from '../../../../src/routes/v2/model/file/getDownloadFile.js'
import { createFixture, testGet } from '../../../testUtils/routes.js'

vi.mock('../../../../src/utils/config.js')
vi.mock('../../../../src/utils/user.js')

const fileMock = vi.hoisted(() => {
return {
getFileById: vi.fn(() => ({
name: 'testFile',
mime: 'text/plain',
size: 12,
})),
downloadFile: vi.fn(() => {
return {
Body: Readable.from(['file content']),
}
}),
}
})
vi.mock('../../../../src/services/v2/file.js', () => fileMock)

describe('routes > files > getDownloadFile', () => {
test('200 > ok', async () => {
const fixture = createFixture(getDownloadFileSchema)
const res = await testGet(`/api/v2/model/${fixture.params.modelId}/file/${fixture.params.fileId}/download`)

expect(res.statusCode).toBe(200)
expect(res.headers['content-disposition']).toBe('inline; filename="testFile"')
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8')
expect(res.headers['content-length']).toBe('12')
})
})
43 changes: 43 additions & 0 deletions backend/test/services/release.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const modelMocks = vi.hoisted(() => ({
}))
vi.mock('../../src/services/v2/model.js', () => modelMocks)

const registryMocks = vi.hoisted(() => ({
listModelImages: vi.fn(),
}))
vi.mock('../../src/services/v2/registry.js', () => registryMocks)

const releaseModelMocks = vi.hoisted(() => {
const obj: any = {}

Expand Down Expand Up @@ -58,6 +63,44 @@ describe('services > release', () => {
expect(mockReviewService.createReleaseReviews).toBeCalled()
})

test('createRelease > release with image', async () => {
const existingImages = [{ repository: 'mockRep', name: 'image', tags: ['latest'] }]
registryMocks.listModelImages.mockResolvedValueOnce(existingImages)
modelMocks.getModelById.mockResolvedValue(undefined)

await createRelease(
{} as any,
{
images: existingImages.flatMap(({ tags, ...rest }) => tags.map((tag) => ({ tag, ...rest }))),
} as any,
)

expect(releaseModelMocks.save).toBeCalled()
expect(releaseModelMocks).toBeCalled()
expect(mockReviewService.createReleaseReviews).toBeCalled()
})

test('createRelease > missing images in the registry', async () => {
const existingImages = [{ repository: 'mockRep', name: 'image', tags: ['latest'] }]
registryMocks.listModelImages.mockResolvedValueOnce(existingImages)
modelMocks.getModelById.mockResolvedValue(undefined)

expect(() =>
createRelease(
{} as any,
{
images: [
{ repository: 'fake', name: 'fake', tag: 'fake1' },
{ repository: 'fake', name: 'fake', tag: 'fake2' },
].concat(existingImages.flatMap(({ tags, ...rest }) => tags.map((tag) => ({ tag, ...rest })))),
} as any,
),
).rejects.toThrowError(/^The following images do not exist in the registry/)
expect(releaseModelMocks.save).not.toBeCalled()
expect(releaseModelMocks).not.toBeCalled()
expect(mockReviewService.createReleaseReviews).not.toBeCalled()
})

test('createRelease > bad authorisation', async () => {
authorisationMocks.userReleaseAction.mockResolvedValueOnce(false)
expect(() => createRelease({} as any, {} as any)).rejects.toThrowError(/^You do not have permission/)
Expand Down
Loading

0 comments on commit ebe81a4

Please sign in to comment.