diff --git a/.gitignore b/.gitignore index 068bc89a5..0ebfdac0d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ backend/certs/* # helm infrastructure/helm/bailo/charts -infrastructure/helm/bailo/local.yaml \ No newline at end of file +infrastructure/helm/bailo/local.yaml +.aider* diff --git a/backend/global.d.ts b/backend/global.d.ts index 9edced48e..775498ebd 100644 --- a/backend/global.d.ts +++ b/backend/global.d.ts @@ -5,6 +5,8 @@ type callback = (err: string | undefined) => void declare namespace Express { interface Request { user: UserDoc + token: TokenDoc + audit: { typeId: string; description: string; auditKind: string } reqId: string diff --git a/backend/src/connectors/v2/audit/Base.ts b/backend/src/connectors/v2/audit/Base.ts index 94fcae9f1..b1bafe5ce 100644 --- a/backend/src/connectors/v2/audit/Base.ts +++ b/backend/src/connectors/v2/audit/Base.ts @@ -6,6 +6,7 @@ import { ModelCardInterface, ModelDoc, ModelInterface } from '../../../models/v2 import { ReleaseDoc } from '../../../models/v2/Release.js' import { ReviewInterface } from '../../../models/v2/Review.js' import { SchemaInterface } from '../../../models/v2/Schema.js' +import { TokenDoc } from '../../../models/v2/Token.js' import { ModelSearchResult } from '../../../routes/v2/model/getModelsSearch.js' import { BailoError } from '../../../types/v2/error.js' @@ -43,6 +44,10 @@ export const AuditInfo = { DeleteRelease: { typeId: 'DeleteRelease', description: 'Release Deleted', auditKind: AuditKind.Delete }, SearchReleases: { typeId: 'SearchReleases', description: 'Release Searched', auditKind: AuditKind.Search }, + CreateUserToken: { typeId: 'CreateUserToken', description: 'Token Created', auditKind: AuditKind.Create }, + ViewUserTokens: { typeId: 'ViewUserToken', description: 'Token Viewed', auditKind: AuditKind.View }, + DeleteUserToken: { typeId: 'DeleteUserToken', description: 'Token Deleted', auditKind: AuditKind.Delete }, + CreateAccessRequest: { typeId: 'CreateAccessRequest', description: 'Access Request Created', @@ -105,6 +110,10 @@ export abstract class BaseAuditConnector { abstract onDeleteRelease(req: Request, modelId: string, semver: string) abstract onSearchReleases(req: Request, releases: ReleaseDoc[]) + abstract onCreateUserToken(req: Request, token: TokenDoc) + abstract onViewUserTokens(req: Request, tokens: TokenDoc[]) + abstract onDeleteUserToken(req: Request, accessKey: string) + abstract onCreateAccessRequest(req: Request, accessRequest: AccessRequestDoc) abstract onViewAccessRequest(req: Request, accessRequest: AccessRequestDoc) abstract onUpdateAccessRequest(req: Request, accessRequest: AccessRequestDoc) diff --git a/backend/src/connectors/v2/audit/silly.ts b/backend/src/connectors/v2/audit/silly.ts index f46c1bf65..1b19ba5e5 100644 --- a/backend/src/connectors/v2/audit/silly.ts +++ b/backend/src/connectors/v2/audit/silly.ts @@ -7,6 +7,7 @@ import { ModelCardInterface, ModelDoc, ModelInterface } from '../../../models/v2 import { ReleaseDoc } from '../../../models/v2/Release.js' import { ReviewInterface } from '../../../models/v2/Review.js' import { SchemaInterface } from '../../../models/v2/Schema.js' +import { TokenDoc } from '../../../models/v2/Token.js' import { ModelSearchResult } from '../../../routes/v2/model/getModelsSearch.js' import { BailoError } from '../../../types/v2/error.js' import { BaseAuditConnector } from './Base.js' @@ -32,6 +33,9 @@ export class SillyAuditConnector extends BaseAuditConnector { onUpdateRelease(_req: Request, _release: ReleaseDoc) {} onDeleteRelease(_req: Request, _modelId: string, _semver: string) {} onSearchReleases(_req: Request, _releases: ReleaseDoc[]) {} + onCreateUserToken(_req: Request, _token: TokenDoc) {} + onViewUserTokens(_req: Request, _tokens: TokenDoc[]) {} + onDeleteUserToken(_req: Request, _accessKey: string) {} onCreateAccessRequest(_req: Request, _accessRequest: AccessRequestDoc) {} onViewAccessRequest(_req: Request, _accessRequest: AccessRequestDoc) {} onUpdateAccessRequest(_req: Request, _accessRequest: AccessRequestDoc) {} diff --git a/backend/src/connectors/v2/audit/stdout.ts b/backend/src/connectors/v2/audit/stdout.ts index 3eb2db4c7..80de2f87d 100644 --- a/backend/src/connectors/v2/audit/stdout.ts +++ b/backend/src/connectors/v2/audit/stdout.ts @@ -6,6 +6,7 @@ import { ModelCardInterface, ModelDoc, ModelInterface } from '../../../models/v2 import { ReleaseDoc } from '../../../models/v2/Release.js' import { ReviewInterface } from '../../../models/v2/Review.js' import { SchemaInterface } from '../../../models/v2/Schema.js' +import { TokenDoc } from '../../../models/v2/Token.js' import { ModelSearchResult } from '../../../routes/v2/model/getModelsSearch.js' import { BailoError } from '../../../types/v2/error.js' import { AuditInfo, AuditInfoKeys, BaseAuditConnector } from './Base.js' @@ -124,6 +125,27 @@ export class StdoutAuditConnector extends BaseAuditConnector { req.log.info(event, req.audit.description) } + onCreateUserToken(req: Request, token: TokenDoc) { + this.checkEventType(AuditInfo.CreateUserToken, req) + const event = this.generateEvent(req, { accessKey: token.accessKey, description: token.description }) + req.log.info(event, req.audit.description) + } + + onViewUserTokens(req: Request, tokens: TokenDoc[]) { + this.checkEventType(AuditInfo.ViewUserTokens, req) + const event = this.generateEvent(req, { + url: req.originalUrl, + results: tokens.map((token) => token.accessKey), + }) + req.log.info(event, req.audit.description) + } + + onDeleteUserToken(req: Request, accessKey: string) { + this.checkEventType(AuditInfo.DeleteUserToken, req) + const event = this.generateEvent(req, { accessKey }) + req.log.info(event, req.audit.description) + } + onCreateAccessRequest(req: Request, accessRequest: AccessRequestDoc) { this.checkEventType(AuditInfo.CreateAccessRequest, req) const event = this.generateEvent(req, { id: accessRequest.id }) diff --git a/backend/src/connectors/v2/authorisation/base.ts b/backend/src/connectors/v2/authorisation/base.ts index 88d550377..4b2db9b66 100644 --- a/backend/src/connectors/v2/authorisation/base.ts +++ b/backend/src/connectors/v2/authorisation/base.ts @@ -154,7 +154,7 @@ export class BasicAuthorisationConnector { async releases( user: UserDoc, model: ModelDoc, - _releases: Array, + releases: Array, action: ReleaseActionKeys, ): Promise> { // We don't have any specific roles dedicated to releases, so we pass it through to the model authorisation checker. @@ -166,7 +166,7 @@ export class BasicAuthorisationConnector { [ReleaseAction.View]: ModelAction.View, } - return this.models(user, [model], actionMap[action]) + return new Array(releases.length).fill(await this.model(user, model, actionMap[action])) } async accessRequests( diff --git a/backend/src/models/v2/Token.ts b/backend/src/models/v2/Token.ts new file mode 100644 index 000000000..32d9cc140 --- /dev/null +++ b/backend/src/models/v2/Token.ts @@ -0,0 +1,102 @@ +import bcrypt from 'bcryptjs' +import { Document, model, Schema } from 'mongoose' +import MongooseDelete from 'mongoose-delete' + +export const TokenScope = { + All: 'all', + Models: 'models', +} as const + +export type TokenScopeKeys = (typeof TokenScope)[keyof typeof TokenScope] + +export const TokenActions = { + ImageRead: 'image:read', + FileRead: 'file:read', +} as const + +export type TokenActionsKeys = (typeof TokenActions)[keyof typeof TokenActions] + +// This interface stores information about the properties on the base object. +// It should be used for plain object representations, e.g. for sending to the +// client. +export interface TokenInterface { + user: string + description: string + + scope: TokenScopeKeys + modelIds: Array + actions: Array + + accessKey: string + secretKey: string + + deleted: boolean + + createdAt: Date + updatedAt: Date + + compareToken: (candidateToken: string) => Promise +} + +// The doc type includes all values in the plain interface, as well as all the +// properties and functions that Mongoose provides. If a function takes in an +// object from Mongoose it should use this interface +export type TokenDoc = TokenInterface & Document + +const TokenSchema = new Schema( + { + user: { type: String, required: true }, + description: { type: String, required: true }, + + scope: { type: String, enum: Object.values(TokenScope), required: true }, + modelIds: [{ type: String }], + actions: [{ type: String, enum: Object.values(TokenActions) }], + + accessKey: { type: String, required: true, unique: true, index: true }, + secretKey: { type: String, required: true, select: false }, + }, + { + timestamps: true, + collection: 'v2_tokens', + }, +) + +TokenSchema.pre('save', function userPreSave(next) { + if (!this.isModified('secretKey') || !this.secretKey) { + next() + return + } + + bcrypt.hash(this.secretKey, 10, (err: Error | undefined, hash: string) => { + if (err) { + next(err) + return + } + + this.secretKey = hash + next() + }) +}) + +TokenSchema.methods.compareToken = function compareToken(candidateToken: string) { + return new Promise((resolve, reject) => { + if (!this.secretKey) { + resolve(false) + return + } + + bcrypt.compare(candidateToken, this.secretKey, (err: Error | undefined, isMatch: boolean) => { + if (err) { + reject(err) + return + } + resolve(isMatch) + }) + }) +} + +TokenSchema.plugin(MongooseDelete, { overrideMethods: 'all' }) + +const TokenModel = model('v2_Token', TokenSchema) + +export default TokenModel diff --git a/backend/src/models/v2/User.ts b/backend/src/models/v2/User.ts index cc60682c9..48a80e842 100644 --- a/backend/src/models/v2/User.ts +++ b/backend/src/models/v2/User.ts @@ -7,9 +7,6 @@ export interface UserInterface { // Do not store user role information on this object. This information // should be stored in an external corporate store. dn: string - - createdAt: Date - updatedAt: Date } // The doc type includes all values in the plain interface, as well as all the diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 08552b565..3e5a65c13 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -6,6 +6,7 @@ import grant from 'grant' import { expressErrorHandler as expressErrorHandlerV2 } from './routes/middleware/expressErrorHandler.js' import { expressLogger as expressLoggerV2 } from './routes/middleware/expressLogger.js' +import { getTokenFromAuthHeader } from './routes/middleware/getToken.js' import { getUser as getUserV2 } from './routes/middleware/getUser.js' import { getApplicationLogs, getItemLogs } from './routes/v1/admin.js' import { getApprovals, getNumApprovals, postApprovalResponse } from './routes/v1/approvals.js' @@ -89,6 +90,9 @@ import { getTeam } from './routes/v2/team/getTeam.js' import { getTeams } from './routes/v2/team/getTeams.js' import { postTeam } from './routes/v2/team/postTeam.js' import { getUiConfig as getUiConfigV2 } from './routes/v2/uiConfig/getUiConfig.js' +import { deleteUserToken } from './routes/v2/user/deleteUserToken.js' +import { getUserTokens } from './routes/v2/user/getUserTokens.js' +import { postUserToken } from './routes/v2/user/postUserToken.js' import config from './utils/config.js' import logger, { expressErrorHandler, expressLogger } from './utils/logger.js' import { getUser } from './utils/user.js' @@ -231,6 +235,8 @@ if (config.experimental.v2) { server.get('/api/v2/model/:modelId/files', ...getFiles) server.get('/api/v2/model/:modelId/file/:fileId/download', ...getDownloadFile) + // This is a temporary workaround to split out the URL to disable authorisation. + server.get('/api/v2/token/model/:modelId/file/:fileId/download', getTokenFromAuthHeader, ...getDownloadFile) server.post('/api/v2/model/:modelId/files/upload/simple', ...postSimpleUpload) server.post('/api/v2/model/:modelId/files/upload/multipart/start', ...postStartMultipartUpload) server.post('/api/v2/model/:modelId/files/upload/multipart/finish', ...postFinishMultipartUpload) @@ -270,10 +276,10 @@ if (config.experimental.v2) { server.get('/api/v2/config/ui', ...getUiConfigV2) - // server.post('/api/v2/user/:userId/tokens', ...postUserToken) - // server.get('/api/v2/user/:userId/tokens', ...getUserTokens) + server.post('/api/v2/user/tokens', ...postUserToken) + server.get('/api/v2/user/tokens', ...getUserTokens) // server.get('/api/v2/user/:userId/token/:tokenId', ...getUserToken) - // server.delete('/api/v2/user/:userId/token/:tokenId', ...deleteUserToken) + server.delete('/api/v2/user/token/:accessKey', ...deleteUserToken) server.get('/api/v2/specification', ...getSpecificationV2) } else { diff --git a/backend/src/routes/middleware/getToken.ts b/backend/src/routes/middleware/getToken.ts new file mode 100644 index 000000000..5d2310bba --- /dev/null +++ b/backend/src/routes/middleware/getToken.ts @@ -0,0 +1,24 @@ +import { NextFunction, Request, Response } from 'express' + +import { getTokenFromAuthHeader as getTokenFromAuthHeaderService } from '../../services/v2/token.js' +import { Forbidden } from '../../utils/v2/error.js' + +export async function getTokenFromAuthHeader(req: Request, _res: Response, next: NextFunction) { + // Unlike 'getUser' this function is currently intended to be used on methods that ONLY authenticate + // using the authentication header. Thus, this function WILL fail and must only be used as middleware + // in functions that MUST use basic auth. + // This let's us provide better error messages for common issues, but could be refactored at a later + // point in time. + const authorization = req.get('authorization') + + if (!authorization) { + throw Forbidden('No authorisation header found') + } + + const token = await getTokenFromAuthHeaderService(authorization) + + req.user = { dn: token.user } + req.token = token + + return next() +} diff --git a/backend/src/routes/v1/registryAuth.ts b/backend/src/routes/v1/registryAuth.ts index d8207dc50..53fdaf256 100644 --- a/backend/src/routes/v1/registryAuth.ts +++ b/backend/src/routes/v1/registryAuth.ts @@ -1,23 +1,27 @@ import bodyParser from 'body-parser' import { createHash, X509Certificate } from 'crypto' -import { Request, Response } from 'express' +import { NextFunction, Request, Response } from 'express' import { readFile } from 'fs/promises' import jwt from 'jsonwebtoken' import { isEqual } from 'lodash-es' import { stringify as uuidStringify, v4 as uuidv4 } from 'uuid' +import audit from '../../connectors/v2/audit/index.js' import authorisation from '../../connectors/v2/authorisation/index.js' import { ModelDoc } from '../../models/v2/Model.js' +import { TokenActions, TokenDoc } from '../../models/v2/Token.js' import { UserDoc as UserDocV2 } from '../../models/v2/User.js' import { findDeploymentByUuid } from '../../services/deployment.js' import log from '../../services/v2/log.js' import { getModelById } from '../../services/v2/model.js' +import { validateTokenForModel } from '../../services/v2/token.js' import { ModelId, UserDoc } from '../../types/types.js' import config from '../../utils/config.js' import { isUserInEntityList } from '../../utils/entity.js' import logger from '../../utils/logger.js' -import { Forbidden } from '../../utils/result.js' +import { Forbidden, Unauthorised } from '../../utils/result.js' import { getUserFromAuthHeader } from '../../utils/user.js' +import { bailoErrorGuard } from '../middleware/expressErrorHandler.js' let adminToken: string | undefined @@ -164,7 +168,7 @@ function generateAccess(scope: any) { } } -async function checkAccessV2(access: Access, user: UserDocV2) { +async function checkAccessV2(access: Access, user: UserDocV2, token: TokenDoc | undefined) { const modelId = access.name.split('/')[0] let model: ModelDoc try { @@ -175,16 +179,24 @@ async function checkAccessV2(access: Access, user: UserDocV2) { return false } + if (token) { + try { + await validateTokenForModel(token, model.id, TokenActions.ImageRead) + } catch (e) { + return false + } + } + const auth = await authorisation.image(user, model, access) return auth.success } -async function checkAccess(access: Access, user: UserDoc) { +async function checkAccess(access: Access, user: UserDoc, token: TokenDoc | undefined) { const modelUuid = access.name.split('/')[0] try { const v2User: UserDocV2 = { createdAt: user.createdAt, updatedAt: user.updatedAt, dn: user.id } as any await getModelById(v2User, modelUuid) - return checkAccessV2(access, v2User) + return checkAccessV2(access, v2User, token) } catch (e) { // do nothing, assume v1 authorisation log.warn({ userId: user.id, access, e }, 'Falling back to V1 authorisation') @@ -232,23 +244,23 @@ export const getDockerRegistryAuth = [ const authorization = req.get('authorization') if (!authorization) { - throw Forbidden({}, 'No authorisation header found', rlog) + throw Unauthorised({}, 'No authorisation header found', rlog) } - const { error, user, admin } = await getUserFromAuthHeader(authorization) + const { error, user, admin, token } = await getUserFromAuthHeader(authorization) if (error) { - throw Forbidden({ error }, error, rlog) + throw Unauthorised({ error }, error, rlog) } if (!user) { - throw Forbidden({}, 'User authentication failed', rlog) + throw Unauthorised({}, 'User authentication failed', rlog) } rlog = rlog.child({ user }) if (service !== config.registry.service) { - throw Forbidden( + throw Unauthorised( { expectedService: config.registry.service }, 'Received registry auth request from unexpected service', rlog, @@ -281,14 +293,46 @@ export const getDockerRegistryAuth = [ const accesses = scopes.map(generateAccess) for (const access of accesses) { - if (!admin && !(await checkAccess(access, user))) { + if (!admin && !(await checkAccess(access, user, token))) { throw Forbidden({ access }, 'User does not have permission to carry out request', rlog) } } - const token = await getAccessToken(user, accesses) + const accessToken = await getAccessToken(user, accesses) rlog.trace('Successfully generated access token') - return res.json({ token }) + return res.json({ token: accessToken }) + }, + async (err: unknown, req: Request, res: Response, _next: NextFunction) => { + if (!bailoErrorGuard(err)) { + log.error({ err }, 'No error code was found, returning generic error to user.') + throw err + } + + const logger = err.logger || req.log + logger.warn(err.context, err.message) + + delete err.context?.internal + + await audit.onError(req, err) + + let dockerCode = 'UNKNOWN' + switch (err.code) { + case 401: + dockerCode = 'UNAUTHORIZED' + break + case 403: + dockerCode = 'DENIED' + } + + return res.status(err.code).json({ + errors: [ + { + code: dockerCode, + message: err.message, + detail: err.context, + }, + ], + }) }, ] diff --git a/backend/src/routes/v2/model/file/getDownloadFile.ts b/backend/src/routes/v2/model/file/getDownloadFile.ts index b5bc8c862..d6e55962a 100644 --- a/backend/src/routes/v2/model/file/getDownloadFile.ts +++ b/backend/src/routes/v2/model/file/getDownloadFile.ts @@ -5,7 +5,9 @@ import stream from 'stream' import { z } from 'zod' import { FileInterface } from '../../../../models/v2/File.js' +import { TokenActions } from '../../../../models/v2/Token.js' import { downloadFile, getFileById } from '../../../../services/v2/file.js' +import { validateTokenForModel } from '../../../../services/v2/token.js' import { BadReq, InternalError } from '../../../../utils/v2/error.js' import { parse } from '../../../../utils/v2/validate.js' @@ -29,6 +31,11 @@ export const getDownloadFile = [ const file = await getFileById(req.user, fileId) + if (req.token) { + // Check that the token can be used for the requested model. + await validateTokenForModel(req.token, file.modelId, TokenActions.FileRead) + } + // required to support utf-8 file names res.set('Content-Disposition', contentDisposition(file.name, { type: 'attachment' })) res.set('Content-Type', file.mime) diff --git a/backend/src/routes/v2/specification.ts b/backend/src/routes/v2/specification.ts index f6a489078..7adc52e5e 100644 --- a/backend/src/routes/v2/specification.ts +++ b/backend/src/routes/v2/specification.ts @@ -55,6 +55,11 @@ export const getSpecification = [ description: 'Schemas are used to define what contents a model card should contain. They follow the JsonSchema specification.', }, + { + name: 'token', + description: + 'Tokens are used to grant access to models. They give constrained permissions to Bailo, allowing fine-grained permissions for deployments.', + }, { name: 'user', description: 'A user represents an individual who has accessed this service.', diff --git a/backend/src/routes/v2/user/deleteUserToken.ts b/backend/src/routes/v2/user/deleteUserToken.ts new file mode 100644 index 000000000..31687b797 --- /dev/null +++ b/backend/src/routes/v2/user/deleteUserToken.ts @@ -0,0 +1,56 @@ +import bodyParser from 'body-parser' +import { Request, Response } from 'express' +import { z } from 'zod' + +import { AuditInfo } from '../../../connectors/v2/audit/Base.js' +import audit from '../../../connectors/v2/audit/index.js' +import { registerPath } from '../../../services/v2/specification.js' +import { removeToken } from '../../../services/v2/token.js' +import { parse } from '../../../utils/v2/validate.js' + +export const deleteUserTokenSchema = z.object({ + params: z.object({ + accessKey: z.string(), + }), +}) + +registerPath({ + method: 'delete', + path: '/api/v2/user/token/{accessKey}', + tags: ['token'], + description: 'Delete a release.', + schema: deleteUserTokenSchema, + responses: { + 200: { + description: 'A success message.', + content: { + 'application/json': { + schema: z.object({ + message: z.string().openapi({ example: 'Succesfully removed access key' }), + }), + }, + }, + }, + }, +}) + +interface DeleteUserTokenResponse { + message: string +} + +export const deleteUserToken = [ + bodyParser.json(), + async (req: Request, res: Response) => { + req.audit = AuditInfo.DeleteAccessRequest + const { + params: { accessKey }, + } = parse(req, deleteUserTokenSchema) + + await removeToken(req.user, accessKey) + await audit.onDeleteUserToken(req, accessKey) + + return res.json({ + message: 'Successfully removed access key.', + }) + }, +] diff --git a/backend/src/routes/v2/user/getUserTokens.ts b/backend/src/routes/v2/user/getUserTokens.ts new file mode 100644 index 000000000..cafbf8ec1 --- /dev/null +++ b/backend/src/routes/v2/user/getUserTokens.ts @@ -0,0 +1,51 @@ +import bodyParser from 'body-parser' +import { Request, Response } from 'express' +import { z } from 'zod' + +import { AuditInfo } from '../../../connectors/v2/audit/Base.js' +import audit from '../../../connectors/v2/audit/index.js' +import { TokenInterface } from '../../../models/v2/Token.js' +import { registerPath, userTokenSchema } from '../../../services/v2/specification.js' +import { findUserTokens } from '../../../services/v2/token.js' +import { parse } from '../../../utils/v2/validate.js' + +export const getUserTokensSchema = z.object({}) + +registerPath({ + method: 'get', + path: '/api/v2/user/tokens', + tags: ['token'], + description: 'Get a list of all user tokens.', + schema: getUserTokensSchema, + responses: { + 200: { + description: 'An array of user tokens.', + content: { + 'application/json': { + schema: z.object({ + tokens: z.array(userTokenSchema), + }), + }, + }, + }, + }, +}) + +interface GetUserTokensResponse { + tokens: Array +} + +export const getUserTokens = [ + bodyParser.json(), + async (req: Request, res: Response) => { + req.audit = AuditInfo.ViewUserTokens + const _ = parse(req, getUserTokensSchema) + + const tokens = await findUserTokens(req.user) + await audit.onViewUserTokens(req, tokens) + + return res.json({ + tokens, + }) + }, +] diff --git a/backend/src/routes/v2/user/postUserToken.ts b/backend/src/routes/v2/user/postUserToken.ts new file mode 100644 index 000000000..b448a4d12 --- /dev/null +++ b/backend/src/routes/v2/user/postUserToken.ts @@ -0,0 +1,59 @@ +import bodyParser from 'body-parser' +import { Request, Response } from 'express' +import { z } from 'zod' + +import { AuditInfo } from '../../../connectors/v2/audit/Base.js' +import audit from '../../../connectors/v2/audit/index.js' +import { TokenActions, TokenInterface, TokenScope } from '../../../models/v2/Token.js' +import { registerPath, userTokenSchema } from '../../../services/v2/specification.js' +import { createToken } from '../../../services/v2/token.js' +import { parse } from '../../../utils/v2/validate.js' + +export const postUserTokenSchema = z.object({ + body: z.object({ + description: z.string().openapi({ example: 'user token' }), + + scope: z.nativeEnum(TokenScope).openapi({ example: 'models' }), + modelIds: z.array(z.string()).openapi({ example: ['yozlo-v4-abcdef'] }), + actions: z.array(z.nativeEnum(TokenActions)).openapi({ example: ['image:read', 'file:read'] }), + }), +}) + +registerPath({ + method: 'post', + path: '/api/v2/user/tokens', + tags: ['token'], + description: 'Create a new user token.', + schema: postUserTokenSchema, + responses: { + 200: { + description: 'The created user token instance.', + content: { + 'application/json': { + schema: z.object({ + token: userTokenSchema, + }), + }, + }, + }, + }, +}) + +interface PostUserTokenResponse { + token: TokenInterface +} + +export const postUserToken = [ + bodyParser.json(), + async (req: Request, res: Response) => { + req.audit = AuditInfo.CreateSchema + const { body } = parse(req, postUserTokenSchema) + + const token = await createToken(req.user, body) + await audit.onCreateUserToken(req, token) + + return res.json({ + token, + }) + }, +] diff --git a/backend/src/services/v2/specification.ts b/backend/src/services/v2/specification.ts index 974263da8..175fe963c 100644 --- a/backend/src/services/v2/specification.ts +++ b/backend/src/services/v2/specification.ts @@ -2,6 +2,7 @@ import { OpenAPIRegistry, RouteConfig } from '@asteasolutions/zod-to-openapi' import { AnyZodObject, z } from 'zod' import { Decision } from '../../models/v2/Review.js' +import { TokenActions, TokenScope } from '../../models/v2/Token.js' import { SchemaKind } from '../../types/v2/enums.js' export const registry = new OpenAPIRegistry() @@ -183,6 +184,20 @@ export const schemaInterfaceSchema = z.object({ updatedAt: z.string().openapi({ example: new Date().toISOString() }), }) +export const userTokenSchema = z.object({ + description: z.string().openapi({ example: 'user token' }), + + scope: z.nativeEnum(TokenScope).openapi({ example: 'models' }), + modelIds: z.array(z.string()).openapi({ example: ['yozlo-v4-abcdef'] }), + actions: z.array(z.nativeEnum(TokenActions)).openapi({ example: ['image:read', 'file:read'] }), + + accessKey: z.string().openapi({ example: 'bailo-iot4hj3890tqaji' }), + secretKey: z.string().openapi({ example: '987895347u89fj389agre' }), + + createdAt: z.string().openapi({ example: new Date().toISOString() }), + updatedAt: z.string().openapi({ example: new Date().toISOString() }), +}) + export const userInterfaceSchema = z.object({ dn: z.string().openapi({ example: 'user' }), diff --git a/backend/src/services/v2/token.ts b/backend/src/services/v2/token.ts new file mode 100644 index 000000000..7a63d10fa --- /dev/null +++ b/backend/src/services/v2/token.ts @@ -0,0 +1,129 @@ +import { nanoid } from 'nanoid' + +import { TokenActionsKeys, TokenDoc, TokenScopeKeys } from '../../models/v2/Token.js' +import Token from '../../models/v2/Token.js' +import { UserDoc } from '../../models/v2/User.js' +import { BadReq, Forbidden, NotFound, Unauthorized } from '../../utils/v2/error.js' +import { getModelById } from './model.js' + +interface CreateTokenProps { + description: string + + scope: TokenScopeKeys + modelIds: Array + actions: Array +} +export async function createToken(user: UserDoc, { description, scope, modelIds, actions }: CreateTokenProps) { + const accessKey = nanoid(10) + const secretKey = nanoid() + + if (scope === 'models') { + // Checks to make sure the models are valid + for (const modelId of modelIds) { + await getModelById(user, modelId) + } + } + + const token = new Token({ + user: user.dn, + description, + + scope, + modelIds, + actions, + + accessKey, + secretKey, + }) + + await token.save() + + token.secretKey = secretKey + return token +} + +export async function findUserTokens(user: UserDoc) { + return Token.find({ + user: user.dn, + }) +} + +export async function removeToken(user: UserDoc, accessKey: string) { + const token = await findTokenByAccessKey(accessKey) + + if (token.user !== user.dn) { + throw Forbidden('Only the token owner can remove the token', { accessKey }) + } + + await token.remove() + + return { success: true } +} + +interface GetTokenOptions { + includeSecretKey?: boolean +} + +export async function findTokenByAccessKey(accessKey: string, opts?: GetTokenOptions) { + let query = Token.findOne({ + accessKey, + }) + + if (opts?.includeSecretKey) { + query = query.select('+secretKey') + } + + const token = await query + + if (!token) { + throw NotFound('Could not find token', { accessKey }) + } + + return token +} + +export async function getTokenFromAuthHeader(header: string) { + // NOTE: This is a security function. Care should be taking when editting this function. + // Any pull requests that alter this MUST be checked out by at least two other people + // familiar with the codebase. + const [method, code] = header.split(' ') + + if (method.toLowerCase() !== 'basic') { + throw BadReq(`Incorrect authorization type, should be 'basic'`, { method }) + } + + const [accessKey, secretKey] = Buffer.from(code, 'base64').toString('utf-8').split(':') + + if (!accessKey || !secretKey) { + // We're explicitly not providing context here, because the error may be logged and + // logs should not include access keys / secret keys. + throw BadReq(`Access key and secret key were not provided.`) + } + + const token = await findTokenByAccessKey(accessKey, { includeSecretKey: true }) + if (!(await token.compareToken(secretKey))) { + throw Unauthorized('Invalid secret key', { accessKey }) + } + + return token +} + +export async function validateTokenForModel(token: TokenDoc, modelId: string, action: TokenActionsKeys) { + if (token.scope === 'models' && !token.modelIds.includes(modelId)) { + throw Unauthorized('This token may not be used for this model', { + accessKey: token.accessKey, + modelIds: token.modelIds, + modelId, + }) + } + + if (!token.actions.includes(action)) { + throw Unauthorized('This token may not be used for this action', { + accessKey: token.accessKey, + actions: token.actions, + action, + }) + } + + return +} diff --git a/backend/src/utils/user.ts b/backend/src/utils/user.ts index bfb6e1bf0..13df23909 100644 --- a/backend/src/utils/user.ts +++ b/backend/src/utils/user.ts @@ -2,8 +2,11 @@ import { timingSafeEqual } from 'crypto' import { NextFunction, Request, Response } from 'express' import Authorisation from '../connectors/Authorisation.js' +import { TokenDoc } from '../models/v2/Token.js' +import { bailoErrorGuard } from '../routes/middleware/expressErrorHandler.js' import { getAdminToken } from '../routes/v1/registryAuth.js' import { findAndUpdateUser, findUserCached, getUserById } from '../services/user.js' +import { getTokenFromAuthHeader } from '../services/v2/token.js' import { UserDoc } from '../types/types.js' import { Forbidden, Unauthorised } from './result.js' @@ -27,7 +30,9 @@ function safelyCompareTokens(expected: string, actual: string) { // This is an authentication function. Take care whilst editing it. Notes: // - the password is not hashed, so comparisons _must_ be done in constant time -export async function getUserFromAuthHeader(header: string): Promise<{ error?: string; user?: any; admin?: boolean }> { +export async function getUserFromAuthHeader( + header: string, +): Promise<{ error?: string; user?: any; admin?: boolean; token?: TokenDoc }> { const [method, code] = header.split(' ') if (method.toLowerCase() !== 'basic') { @@ -46,17 +51,39 @@ export async function getUserFromAuthHeader(header: string): Promise<{ error?: s const user = await getUserById(username, { includeToken: true }) - if (!user) { - return { error: 'User not found' } + if (user) { + const isValid = await user.compareToken(token) + + if (!isValid) { + return { error: 'Incorrect token is wrong' } + } + + return { user } } - const isValid = await user.compareToken(token) + let tokenDoc: TokenDoc | undefined = undefined + try { + tokenDoc = await getTokenFromAuthHeader(header) + } catch (e: unknown) { + if (bailoErrorGuard(e) && e.code) { + return { error: e.message } + } else { + throw e + } + } - if (!isValid) { - return { error: 'Incorrect token is wrong' } + if (!tokenDoc) { + return { error: 'No user found' } } - return { user } + return { + token: tokenDoc, + user: { + _id: tokenDoc.user, + id: tokenDoc.user, + dn: tokenDoc.user, + }, + } } export async function getUser(req: Request, _res: Response, next: NextFunction) { diff --git a/backend/test/routes/model/file/deleteFile.spec.ts b/backend/test/routes/model/file/deleteFile.spec.ts index 53086f8c6..d274eeff3 100644 --- a/backend/test/routes/model/file/deleteFile.spec.ts +++ b/backend/test/routes/model/file/deleteFile.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') describe('routes > file > deleteFile', () => { test('200 > ok', async () => { diff --git a/backend/test/routes/model/file/getFiles.spec.ts b/backend/test/routes/model/file/getFiles.spec.ts index 121235838..a52afdbae 100644 --- a/backend/test/routes/model/file/getFiles.spec.ts +++ b/backend/test/routes/model/file/getFiles.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') const fileMock = vi.hoisted(() => { return { diff --git a/backend/test/routes/model/file/postSimpleUpload.spec.ts b/backend/test/routes/model/file/postSimpleUpload.spec.ts index 537b17fac..63be48563 100644 --- a/backend/test/routes/model/file/postSimpleUpload.spec.ts +++ b/backend/test/routes/model/file/postSimpleUpload.spec.ts @@ -9,6 +9,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') vi.mock('../../../../src/services/v2/file.js', () => ({ uploadFile: vi.fn(() => ({ _id: 'test' })), diff --git a/backend/test/routes/model/images/getImages.spec.ts b/backend/test/routes/model/images/getImages.spec.ts index 8c87e6fcd..918a2e857 100644 --- a/backend/test/routes/model/images/getImages.spec.ts +++ b/backend/test/routes/model/images/getImages.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') vi.mock('../../../../src/services/v2/registry.js', () => ({ listModelImages: vi.fn(() => [{ _id: 'test' }]), diff --git a/backend/test/routes/model/release/deleteRelease.spec.ts b/backend/test/routes/model/release/deleteRelease.spec.ts index 30dfbe7a5..b7a71231a 100644 --- a/backend/test/routes/model/release/deleteRelease.spec.ts +++ b/backend/test/routes/model/release/deleteRelease.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') describe('routes > release > deleteRelease', () => { test('200 > ok', async () => { diff --git a/backend/test/routes/model/release/getRelease.spec.ts b/backend/test/routes/model/release/getRelease.spec.ts index 2ba07921a..2bd81dcf2 100644 --- a/backend/test/routes/model/release/getRelease.spec.ts +++ b/backend/test/routes/model/release/getRelease.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') vi.mock('../../../../src/services/v2/release.js', () => ({ getReleaseBySemver: vi.fn(() => ({ _id: 'test', toObject: vi.fn(() => ({ _id: 'test' })) })), diff --git a/backend/test/routes/model/release/getReleases.spec.ts b/backend/test/routes/model/release/getReleases.spec.ts index 936224521..5cd884616 100644 --- a/backend/test/routes/model/release/getReleases.spec.ts +++ b/backend/test/routes/model/release/getReleases.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') vi.mock('../../../../src/services/v2/release.js', () => ({ getModelReleases: vi.fn(() => [{ _id: 'test' }]), diff --git a/backend/test/routes/model/release/postRelease.spec.ts b/backend/test/routes/model/release/postRelease.spec.ts index 44ed0ccd0..70f29a3fb 100644 --- a/backend/test/routes/model/release/postRelease.spec.ts +++ b/backend/test/routes/model/release/postRelease.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') vi.mock('../../../../src/services/v2/release.js', () => ({ createRelease: vi.fn(() => ({ _id: 'test' })), diff --git a/backend/test/routes/model/release/putRelease.spec.ts b/backend/test/routes/model/release/putRelease.spec.ts index 1c1400010..fe262d259 100644 --- a/backend/test/routes/model/release/putRelease.spec.ts +++ b/backend/test/routes/model/release/putRelease.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../../src/utils/config.js') vi.mock('../../../../src/utils/user.js') vi.mock('../../../../src/utils/v2/config.js') vi.mock('../../../../src/connectors/v2/audit/index.js') +vi.mock('../../../../src/connectors/v2/authorisation/index.js') vi.mock('../../../../src/services/v2/release.js', () => ({ updateRelease: vi.fn(() => ({ _id: 'test' })), diff --git a/backend/test/routes/review/getReview.spec.ts b/backend/test/routes/review/getReview.spec.ts index 18c87dcf3..143ac582b 100644 --- a/backend/test/routes/review/getReview.spec.ts +++ b/backend/test/routes/review/getReview.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../src/utils/v2/config.js') vi.mock('../../../src/utils/config.js') vi.mock('../../../src/utils/user.js') vi.mock('../../../src/connectors/v2/audit/index.js') +vi.mock('../../../src/connectors/v2/authorisation/index.js') const reviews = [testReleaseReviewWithResponses] const mockReviewService = vi.hoisted(() => { diff --git a/backend/test/routes/review/postAccessRequestReviewResponse.spec.ts b/backend/test/routes/review/postAccessRequestReviewResponse.spec.ts index e133c761b..0c2fc0ee2 100644 --- a/backend/test/routes/review/postAccessRequestReviewResponse.spec.ts +++ b/backend/test/routes/review/postAccessRequestReviewResponse.spec.ts @@ -9,6 +9,7 @@ vi.mock('../../../src/utils/v2/config.js') vi.mock('../../../src/utils/config.js') vi.mock('../../../src/utils/user.js') vi.mock('../../../src/connectors/v2/audit/index.js') +vi.mock('../../../src/connectors/v2/authorisation/index.js') const mockReviewService = vi.hoisted(() => { return { diff --git a/backend/test/routes/review/postReleaseReviewResponse.spec.ts b/backend/test/routes/review/postReleaseReviewResponse.spec.ts index b62a52186..ee0b7b54c 100644 --- a/backend/test/routes/review/postReleaseReviewResponse.spec.ts +++ b/backend/test/routes/review/postReleaseReviewResponse.spec.ts @@ -9,6 +9,7 @@ vi.mock('../../../src/utils/v2/config.js') vi.mock('../../../src/utils/config.js') vi.mock('../../../src/utils/user.js') vi.mock('../../../src/connectors/v2/audit/index.js') +vi.mock('../../../src/connectors/v2/authorisation/index.js') const mockReviewService = vi.hoisted(() => { return { diff --git a/backend/test/routes/schema/getSchema.spec.ts b/backend/test/routes/schema/getSchema.spec.ts index fa72a6f58..4a3491996 100644 --- a/backend/test/routes/schema/getSchema.spec.ts +++ b/backend/test/routes/schema/getSchema.spec.ts @@ -9,6 +9,7 @@ vi.mock('../../../src/utils/config.js') vi.mock('../../../src/utils/user.js') vi.mock('../../../src/utils/v2/config.js') vi.mock('../../../src/connectors/v2/audit/index.js') +vi.mock('../../../src/connectors/v2/authorisation/index.js') const mockSchemaService = vi.hoisted(() => { return { diff --git a/backend/test/routes/schema/getSchemas.spec.ts b/backend/test/routes/schema/getSchemas.spec.ts index ec2873ab0..ad0d398b8 100644 --- a/backend/test/routes/schema/getSchemas.spec.ts +++ b/backend/test/routes/schema/getSchemas.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../src/utils/config.js') vi.mock('../../../src/utils/user.js') vi.mock('../../../src/utils/v2/config.js') vi.mock('../../../src/connectors/v2/audit/index.js') +vi.mock('../../../src/connectors/v2/authorisation/index.js') const mockSchemaService = vi.hoisted(() => { return { diff --git a/backend/test/routes/schema/postSchema.spec.ts b/backend/test/routes/schema/postSchema.spec.ts index fa5efef12..682de24ab 100644 --- a/backend/test/routes/schema/postSchema.spec.ts +++ b/backend/test/routes/schema/postSchema.spec.ts @@ -8,6 +8,7 @@ vi.mock('../../../src/utils/user.js') vi.mock('../../../src/utils/config.js') vi.mock('../../../src/utils/v2/config.js') vi.mock('../../../src/connectors/v2/audit/index.js') +vi.mock('../../../src/connectors/v2/authorisation/index.js') const mockSchemaService = vi.hoisted(() => { return {