Skip to content

Commit

Permalink
Merge pull request #987 from gchq/feature/user-tokens
Browse files Browse the repository at this point in the history
Initial work on user tokens
  • Loading branch information
a3957273 authored Jan 3, 2024
2 parents 6bfa8e0 + 50764f0 commit 4fb6802
Show file tree
Hide file tree
Showing 34 changed files with 604 additions and 29 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ backend/certs/*

# helm
infrastructure/helm/bailo/charts
infrastructure/helm/bailo/local.yaml
infrastructure/helm/bailo/local.yaml
.aider*
2 changes: 2 additions & 0 deletions backend/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions backend/src/connectors/v2/audit/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions backend/src/connectors/v2/audit/silly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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) {}
Expand Down
22 changes: 22 additions & 0 deletions backend/src/connectors/v2/audit/stdout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })
Expand Down
4 changes: 2 additions & 2 deletions backend/src/connectors/v2/authorisation/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export class BasicAuthorisationConnector {
async releases(
user: UserDoc,
model: ModelDoc,
_releases: Array<ReleaseDoc>,
releases: Array<ReleaseDoc>,
action: ReleaseActionKeys,
): Promise<Array<Response>> {
// We don't have any specific roles dedicated to releases, so we pass it through to the model authorisation checker.
Expand All @@ -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(
Expand Down
102 changes: 102 additions & 0 deletions backend/src/models/v2/Token.ts
Original file line number Diff line number Diff line change
@@ -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<string>
actions: Array<TokenActionsKeys>

accessKey: string
secretKey: string

deleted: boolean

createdAt: Date
updatedAt: Date

compareToken: (candidateToken: string) => Promise<boolean>
}

// 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<any, any, TokenInterface>

const TokenSchema = new Schema<TokenInterface>(
{
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<TokenInterface>('v2_Token', TokenSchema)

export default TokenModel
3 changes: 0 additions & 3 deletions backend/src/models/v2/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions backend/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions backend/src/routes/middleware/getToken.ts
Original file line number Diff line number Diff line change
@@ -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()
}
Loading

0 comments on commit 4fb6802

Please sign in to comment.