Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch hashes to SHA-256 and implement token limitations #1263

Merged
merged 7 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ type callback = (err: string | undefined) => void
declare namespace Express {
interface Request {
user: UserInterface
token: TokenDoc

audit: { typeId: string; description: string; auditKind: string }

Expand Down
7 changes: 7 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@faker-js/faker": "^8.0.2",
"@swc/core": "^1.4.4",
"@types/archiver": "^6.0.2",
"@types/bcryptjs": "^2.4.6",
"@types/bunyan": "^1.8.11",
"@types/clamscan": "^2.0.8",
"@types/cls-hooked": "^4.3.8",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/connectors/authentication/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export abstract class BaseAuthenticationConnector {
},
{
path: '/api/v2',
middleware: [checkAuthentication],
middleware: [getTokenFromAuthHeader, checkAuthentication],
},
]
}
Expand Down
91 changes: 64 additions & 27 deletions backend/src/connectors/authorisation/actions.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,83 @@
import { TokenActions } from '../../models/Token.js'

export const ModelAction = {
Create: 'create',
View: 'view',
Update: 'update',
Write: 'write',
Create: 'model:create',
View: 'model:view',
Update: 'model:update',
Write: 'model:write',
} as const
export type ModelActionKeys = (typeof ModelAction)[keyof typeof ModelAction]

export const ReleaseAction = {
Create: 'create',
View: 'view',
Delete: 'delete',
Update: 'update',
}
Create: 'release:create',
View: 'release:view',
Delete: 'release:delete',
Update: 'release:update',
} as const
export type ReleaseActionKeys = (typeof ReleaseAction)[keyof typeof ReleaseAction]

export const AccessRequestAction = {
Create: 'create',
View: 'view',
Update: 'update',
Delete: 'delete',
}
Create: 'access_request:create',
View: 'access_request:view',
Update: 'access_request:update',
Delete: 'access_request:delete',
} as const
export type AccessRequestActionKeys = (typeof AccessRequestAction)[keyof typeof AccessRequestAction]

export const SchemaAction = {
Create: 'create',
Delete: 'delete',
Update: 'update',
}
Create: 'schema:create',
Delete: 'schema:delete',
Update: 'schema:update',
} as const
export type SchemaActionKeys = (typeof SchemaAction)[keyof typeof SchemaAction]

export const FileAction = {
Delete: 'delete',
Upload: 'upload',
Delete: 'file:delete',
Upload: 'file:upload',
// 'view' refers to the ability to see metadata about the file. 'download' lets the user view the file contents.
View: 'view',
Download: 'download',
}
View: 'file:view',
Download: 'file:download',
} as const
export type FileActionKeys = (typeof FileAction)[keyof typeof FileAction]

export const ImageAction = {
Pull: 'pull',
Push: 'push',
List: 'list',
}
Pull: 'image:pull',
Push: 'image:push',
List: 'image:list',
Wildcard: 'image:wildcard',
Delete: 'image:delete',
} as const
export type ImageActionKeys = (typeof ImageAction)[keyof typeof ImageAction]

export const ActionLookup = {
[ModelAction.Create]: TokenActions.ModelWrite,
[ModelAction.View]: TokenActions.ModelRead,
[ModelAction.Update]: TokenActions.ModelWrite,
[ModelAction.Write]: TokenActions.ModelWrite,

[ReleaseAction.Create]: TokenActions.ReleaseWrite,
[ReleaseAction.View]: TokenActions.ReleaseRead,
[ReleaseAction.Delete]: TokenActions.ReleaseWrite,
[ReleaseAction.Update]: TokenActions.ReleaseWrite,

[AccessRequestAction.Create]: TokenActions.AccessRequestWrite,
[AccessRequestAction.View]: TokenActions.AccessRequestRead,
[AccessRequestAction.Update]: TokenActions.AccessRequestWrite,
[AccessRequestAction.Delete]: TokenActions.AccessRequestWrite,

[SchemaAction.Create]: TokenActions.SchemaWrite,
[SchemaAction.Delete]: TokenActions.SchemaWrite,
[SchemaAction.Update]: TokenActions.SchemaWrite,

[FileAction.Delete]: TokenActions.FileWrite,
[FileAction.Upload]: TokenActions.FileWrite,
[FileAction.View]: TokenActions.FileRead,
[FileAction.Download]: TokenActions.FileRead,

[ImageAction.Pull]: TokenActions.ImageRead,
[ImageAction.Push]: TokenActions.ImageWrite,
[ImageAction.List]: TokenActions.ImageRead,
[ImageAction.Wildcard]: TokenActions.ImageWrite,
[ImageAction.Delete]: TokenActions.ImageWrite,
} as const
export type ActionLookupKeys = (typeof ActionLookup)[keyof typeof ActionLookup]
100 changes: 81 additions & 19 deletions backend/src/connectors/authorisation/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import { UserInterface } from '../../models/User.js'
import { Access, Action } from '../../routes/v1/registryAuth.js'
import { getModelAccessRequestsForUser } from '../../services/accessRequest.js'
import { checkAccessRequestsApproved } from '../../services/review.js'
import { validateTokenForModel, validateTokenForUse } from '../../services/token.js'
import { Roles } from '../authentication/Base.js'
import authentication from '../authentication/index.js'
import {
AccessRequestAction,
AccessRequestActionKeys,
ActionLookup,
FileAction,
FileActionKeys,
ImageAction,
ImageActionKeys,
ModelAction,
ModelActionKeys,
ReleaseAction,
Expand All @@ -23,7 +26,7 @@ import {
SchemaActionKeys,
} from './actions.js'

type Response = { id: string; success: true } | { id: string; success: false; info: string }
export type Response = { id: string; success: true } | { id: string; success: false; info: string }

export class BasicAuthorisationConnector {
async hasModelVisibilityAccess(user: UserInterface, model: ModelDoc) {
Expand Down Expand Up @@ -77,6 +80,12 @@ export class BasicAuthorisationConnector {
async models(user: UserInterface, models: Array<ModelDoc>, action: ModelActionKeys): Promise<Array<Response>> {
return Promise.all(
models.map(async (model) => {
// Is this a constrained user token.
const tokenAuth = await validateTokenForModel(user.token, model.id, ActionLookup[action])
if (!tokenAuth.success) {
return tokenAuth
}

// Prohibit non-collaborators from seeing private models
if (!(await this.hasModelVisibilityAccess(user, model))) {
return {
Expand All @@ -100,22 +109,32 @@ export class BasicAuthorisationConnector {
}

async schemas(user: UserInterface, schemas: Array<SchemaDoc>, action: SchemaActionKeys): Promise<Array<Response>> {
if (action === SchemaAction.Create || action === SchemaAction.Delete) {
const isAdmin = await authentication.hasRole(user, Roles.Admin)
return Promise.all(
schemas.map(async (schema) => {
// Is this a constrained user token.
const tokenAuth = await validateTokenForUse(user.token, ActionLookup[action])
if (!tokenAuth.success) {
return tokenAuth
}

if (!isAdmin) {
return schemas.map((schema) => ({
id: schema.id,
success: false,
info: 'You cannot upload or modify a schema if you are not an admin.',
}))
}
}
if (action === SchemaAction.Create || action === SchemaAction.Delete) {
const isAdmin = await authentication.hasRole(user, Roles.Admin)

return schemas.map((schema) => ({
id: schema.id,
success: true,
}))
if (!isAdmin) {
return {
id: schema.id,
success: false,
info: 'You cannot upload or modify a schema if you are not an admin.',
}
}
}

return {
id: schema.id,
success: true,
}
}),
)
}

async releases(
Expand All @@ -133,6 +152,12 @@ export class BasicAuthorisationConnector {
[ReleaseAction.View]: ModelAction.View,
}

// Is this a constrained user token.
const tokenAuth = await validateTokenForModel(user.token, model.id, ActionLookup[action])
if (!tokenAuth.success) {
return releases.map(() => tokenAuth)
}

return new Array(releases.length).fill(await this.model(user, model, actionMap[action]))
}

Expand All @@ -146,6 +171,12 @@ export class BasicAuthorisationConnector {

return Promise.all(
accessRequests.map(async (request) => {
// Is this a constrained user token.
const tokenAuth = await validateTokenForModel(user.token, model.id, ActionLookup[action])
if (!tokenAuth.success) {
return tokenAuth
}

// Does any individual in the access request share an entity with our user?
const isNamed = request.metadata.overview.entities.some((value) => entities.includes(value))

Expand All @@ -158,7 +189,7 @@ export class BasicAuthorisationConnector {
if (
!isNamed &&
(await missingRequiredRole(user, model, ['owner'])) &&
[AccessRequestAction.Delete, AccessRequestAction.Update].includes(action)
([AccessRequestAction.Delete, AccessRequestAction.Update] as AccessRequestActionKeys[]).includes(action)
) {
return { success: false, info: 'You cannot change an access request you do not own', id: request.id }
}
Expand All @@ -180,9 +211,15 @@ export class BasicAuthorisationConnector {

return Promise.all(
files.map(async (file) => {
// Is this a constrained user token.
const tokenAuth = await validateTokenForModel(user.token, model.id, ActionLookup[action])
if (!tokenAuth.success) {
return tokenAuth
}

// If they are not listed on the model, don't let them upload or delete files.
if (
[FileAction.Delete, FileAction.Upload].includes(action) &&
([FileAction.Delete, FileAction.Upload] as FileActionKeys[]).includes(action) &&
(await missingRequiredRole(user, model, ['owner', 'collaborator']))
) {
return {
Expand All @@ -193,7 +230,7 @@ export class BasicAuthorisationConnector {
}

if (
[FileAction.Download].includes(action) &&
([FileAction.Download] as FileActionKeys[]).includes(action) &&
!model.settings.ungovernedAccess &&
!hasApprovedAccessRequest &&
(await missingRequiredRole(user, model, ['owner', 'collaborator', 'consumer']))
Expand All @@ -216,9 +253,26 @@ export class BasicAuthorisationConnector {

return Promise.all(
accesses.map(async (access) => {
const actions = access.actions.map((action) => {
switch (action) {
case '*':
return ImageAction.Wildcard
case 'delete':
return ImageAction.Delete
case 'list':
return ImageAction.List
case 'pull':
return ImageAction.Pull
case 'push':
return ImageAction.Push
}
})

// Don't allow anything beyond pushing and pulling actions.
if (
!access.actions.every((action) => [ImageAction.Push, ImageAction.Pull, ImageAction.List].includes(action))
!actions.every((action) =>
([ImageAction.Push, ImageAction.Pull, ImageAction.List] as ImageActionKeys[]).includes(action),
)
) {
return {
success: false,
Expand All @@ -227,6 +281,14 @@ export class BasicAuthorisationConnector {
}
}

// Is this a constrained user token.
for (const action of actions) {
const tokenAuth = await validateTokenForModel(user.token, model.id, ActionLookup[action])
if (!tokenAuth.success) {
return tokenAuth
}
}

// If they are not listed on the model, don't let them upload or delete images.
if (
(await missingRequiredRole(user, model, ['owner', 'collaborator'])) &&
Expand Down
16 changes: 16 additions & 0 deletions backend/src/migrations/003_add_token_hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import TokenModel, { HashType } from '../models/Token.js'

export async function up() {
const tokens = await TokenModel.find({})

for (const token of tokens) {
if (token.hashMethod === undefined) {
token.hashMethod = HashType.Bcrypt
await token.save()
}
}
}

export async function down() {
/* NOOP */
}
Loading
Loading