Skip to content

Commit e2d0623

Browse files
authored
Merge pull request #1624 from gchq/feature/BAI-1485-manual-user-access
Feature/bai 1485 manual user access
2 parents 44e4911 + 27697e6 commit e2d0623

File tree

15 files changed

+241
-12
lines changed

15 files changed

+241
-12
lines changed

Diff for: backend/config/default.cjs

+4
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ module.exports = {
201201
text: '',
202202
startTimestamp: '',
203203
},
204+
205+
helpPopoverText: {
206+
manualEntryAccess: '',
207+
},
204208
},
205209

206210
connectors: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import authentication from '../connectors/authentication/index.js'
2+
import { MigrationMetadata } from '../models/Migration.js'
3+
import ModelModel from '../models/Model.js'
4+
5+
/**
6+
* As we now do backend validation for users being added to model access lists, we
7+
* added this script to find and remove all existing users that do not pass the
8+
* "getUserInformation" call in the authentication connector. You can find a
9+
* list of removed users for all affected models by looking at the "metadata"
10+
* property of this migration's database object.
11+
**/
12+
13+
export async function up() {
14+
const models = await ModelModel.find({})
15+
const metadata: MigrationMetadata[] = []
16+
for (const model of models) {
17+
const invalidUsers: string[] = []
18+
await Promise.all(
19+
model.collaborators.map(async (collaborator) => {
20+
if (collaborator.entity !== '') {
21+
try {
22+
await authentication.getUserInformation(collaborator.entity)
23+
} catch (err) {
24+
invalidUsers.push(collaborator.entity)
25+
}
26+
}
27+
}),
28+
)
29+
if (invalidUsers.length > 0) {
30+
const invalidUsersForModel = { modelId: model.id, invalidUsers: invalidUsers }
31+
const invalidUsersRemoved = model.collaborators.filter(
32+
(collaborator) => !invalidUsers.includes(collaborator.entity),
33+
)
34+
model.collaborators = invalidUsersRemoved
35+
await model.save()
36+
metadata.push(invalidUsersForModel)
37+
}
38+
}
39+
return metadata
40+
}
41+
42+
export async function down() {
43+
/* NOOP */
44+
}

Diff for: backend/src/models/Migration.ts

+6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { Document, model, Schema } from 'mongoose'
22

3+
export interface MigrationMetadata {
4+
[key: string]: any
5+
}
6+
37
export interface Migration {
48
name: string
9+
metadata?: MigrationMetadata
510

611
createdAt: Date
712
updatedAt: Date
@@ -12,6 +17,7 @@ export type MigrationDoc = Migration & Document<any, any, Migration>
1217
const MigrationSchema = new Schema<Migration>(
1318
{
1419
name: { type: String, required: true },
20+
metadata: { type: Schema.Types.Mixed },
1521
},
1622
{
1723
timestamps: true,

Diff for: backend/src/services/migration.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import MigrationModel from '../models/Migration.js'
1+
import MigrationModel, { MigrationMetadata } from '../models/Migration.js'
22

33
export async function doesMigrationExist(name: string) {
44
const migration = await MigrationModel.findOne({
@@ -12,8 +12,13 @@ export async function doesMigrationExist(name: string) {
1212
return true
1313
}
1414

15-
export async function markMigrationComplete(name: string) {
16-
await MigrationModel.create({
17-
name,
18-
})
15+
export async function markMigrationComplete(name: string, metadata: MigrationMetadata | undefined) {
16+
metadata
17+
? await MigrationModel.create({
18+
name,
19+
metadata,
20+
})
21+
: await MigrationModel.create({
22+
name,
23+
})
1924
}

Diff for: backend/src/services/model.ts

+53-3
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import ModelCardRevisionModel, {
1212
} from '../models/ModelCardRevision.js'
1313
import { UserInterface } from '../models/User.js'
1414
import { GetModelCardVersionOptions, GetModelCardVersionOptionsKeys, GetModelFiltersKeys } from '../types/enums.js'
15-
import { EntryUserPermissions } from '../types/types.js'
15+
import { EntityKind, EntryUserPermissions } from '../types/types.js'
1616
import { isValidatorResultError } from '../types/ValidatorResultError.js'
17-
import { toEntity } from '../utils/entity.js'
17+
import { fromEntity, toEntity } from '../utils/entity.js'
1818
import { BadReq, Forbidden, InternalError, NotFound } from '../utils/error.js'
1919
import { convertStringToId } from '../utils/id.js'
2020
import { authResponseToUserPermission } from '../utils/permissions.js'
@@ -33,6 +33,10 @@ export type CreateModelParams = Pick<
3333
export async function createModel(user: UserInterface, modelParams: CreateModelParams) {
3434
const modelId = convertStringToId(modelParams.name)
3535

36+
if (modelParams.collaborators) {
37+
await validateCollaborators(modelParams.collaborators)
38+
}
39+
3640
let collaborators: CollaboratorEntry[] = []
3741
if (modelParams.collaborators && modelParams.collaborators.length > 0) {
3842
const collaboratorListContainsOwner = modelParams.collaborators.some((collaborator) =>
@@ -303,7 +307,7 @@ export async function updateModelCard(
303307
return revision
304308
}
305309

306-
export type UpdateModelParams = Pick<ModelInterface, 'name' | 'description' | 'visibility'> & {
310+
export type UpdateModelParams = Pick<ModelInterface, 'name' | 'description' | 'visibility' | 'collaborators'> & {
307311
settings: Partial<ModelInterface['settings']>
308312
}
309313
export async function updateModel(user: UserInterface, modelId: string, modelDiff: Partial<UpdateModelParams>) {
@@ -317,6 +321,9 @@ export async function updateModel(user: UserInterface, modelId: string, modelDif
317321
if (modelDiff.settings?.mirror?.destinationModelId && modelDiff.settings?.mirror?.sourceModelId) {
318322
throw BadReq('You cannot select both mirror settings simultaneously.')
319323
}
324+
if (modelDiff.collaborators) {
325+
await validateCollaborators(modelDiff.collaborators, model.collaborators)
326+
}
320327

321328
const auth = await authorisation.model(user, model, ModelAction.Update)
322329
if (!auth.success) {
@@ -335,6 +342,49 @@ export async function updateModel(user: UserInterface, modelId: string, modelDif
335342
return model
336343
}
337344

345+
async function validateCollaborators(
346+
updatedCollaborators: CollaboratorEntry[],
347+
previousCollaborators: CollaboratorEntry[] = [],
348+
) {
349+
const previousCollaboratorEntities: string[] = previousCollaborators.map((collaborator) => collaborator.entity)
350+
const duplicates = updatedCollaborators.reduce<string[]>(
351+
(duplicates, currentCollaborator, currentCollaboratorIndex) => {
352+
if (
353+
updatedCollaborators.find(
354+
(collaborator, index) =>
355+
index !== currentCollaboratorIndex && collaborator.entity === currentCollaborator.entity,
356+
) &&
357+
!duplicates.includes(currentCollaborator.entity)
358+
) {
359+
duplicates.push(currentCollaborator.entity)
360+
}
361+
return duplicates
362+
},
363+
[],
364+
)
365+
if (duplicates.length > 0) {
366+
throw BadReq(`The following duplicate collaborators have been found: ${duplicates.join(', ')}`)
367+
}
368+
const newCollaborators = updatedCollaborators.reduce<string[]>((acc, currentCollaborator) => {
369+
if (!previousCollaboratorEntities.includes(currentCollaborator.entity)) {
370+
acc.push(currentCollaborator.entity)
371+
}
372+
return acc
373+
}, [])
374+
await Promise.all(
375+
newCollaborators.map(async (collaborator) => {
376+
if (collaborator === '') {
377+
throw BadReq('Collaborator name must be a valid string')
378+
}
379+
// TODO we currently only check for users, we should consider how we want to handle groups
380+
const { kind } = fromEntity(collaborator)
381+
if (kind === EntityKind.USER) {
382+
await authentication.getUserInformation(collaborator)
383+
}
384+
}),
385+
)
386+
}
387+
338388
export async function createModelCardFromSchema(
339389
user: UserInterface,
340390
modelId: string,

Diff for: backend/src/types/types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export const RoleKind = {
99
SCHEMA: 'schema',
1010
} as const
1111

12+
export enum EntityKind {
13+
USER = 'user',
14+
GROUP = 'group',
15+
}
16+
1217
export type RoleKindKeys = (typeof RoleKind)[keyof typeof RoleKind]
1318

1419
export interface Role {
@@ -91,4 +96,8 @@ export interface UiConfig {
9196
text: string
9297
startTimestamp: string
9398
}
99+
100+
helpPopoverText: {
101+
manualEntryAccess: string
102+
}
94103
}

Diff for: backend/src/utils/database.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ export async function runMigrations() {
5757

5858
// run migration
5959
const migration = await import(join(base, file))
60-
await migration.up()
60+
const runMigration = await migration.up()
6161

62-
await markMigrationComplete(file)
62+
await markMigrationComplete(file, runMigration)
6363

6464
log.info({ file }, `Finished migration ${file}`)
6565
}

Diff for: backend/test/services/model.spec.ts

+19
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ vi.mock('../../src/models/Model.js', () => ({ default: modelMocks }))
8686

8787
const authenticationMocks = vi.hoisted(() => ({
8888
getEntities: vi.fn(() => ['user']),
89+
getUserInformation: vi.fn(() => ({ name: 'user', email: '[email protected]' })),
8990
}))
9091
vi.mock('../../src/connectors/authentication/index.js', async () => ({
9192
default: authenticationMocks,
@@ -119,6 +120,15 @@ describe('services > model', () => {
119120
expect(modelMocks.save).not.toBeCalled()
120121
})
121122

123+
test('createModel > should throw an internal error if getUserInformation fails due to invalid user', async () => {
124+
authenticationMocks.getUserInformation.mockImplementation(() => {
125+
throw new Error('Unable to find user user:unknown_user')
126+
})
127+
expect(() =>
128+
createModel({} as any, { collaborators: [{ entity: 'user:unknown_user', roles: [] }] } as any),
129+
).rejects.toThrowError(/^Unable to find user user:unknown_user/)
130+
})
131+
122132
test('getModelById > good', async () => {
123133
modelMocks.findOne.mockResolvedValueOnce('mocked')
124134

@@ -326,6 +336,15 @@ describe('services > model', () => {
326336
).rejects.toThrowError(/^You cannot select both mirror settings simultaneously./)
327337
})
328338

339+
test('updateModel > should throw an internal error if getUserInformation fails due to invalid user', async () => {
340+
authenticationMocks.getUserInformation.mockImplementation(() => {
341+
throw new Error('Unable to find user user:unknown_user')
342+
})
343+
expect(() =>
344+
updateModel({} as any, '123', { collaborators: [{ entity: 'user:unknown_user', roles: [] }] }),
345+
).rejects.toThrowError(/^Unable to find user user:unknown_user/)
346+
})
347+
329348
test('createModelcardFromSchema > should throw an error when attempting to change a model from mirrored to standard', async () => {
330349
vi.mocked(authorisation.model).mockResolvedValue({
331350
info: 'Cannot alter a mirrored model.',

Diff for: frontend/src/common/HelpDialog.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export default function HelpDialog({ title, content }: HelpDialogProps) {
2222
<>
2323
<Tooltip title={title}>
2424
<IconButton size='small' onClick={handleOpen}>
25-
<HelpOutlineIcon />
25+
<HelpOutlineIcon color='primary' />
2626
</IconButton>
2727
</Tooltip>
2828
<Dialog open={open} onClose={handleClose} maxWidth='md' TransitionComponent={Transition}>

Diff for: frontend/src/common/HelpPopover.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function HelpPopover({ anchorOrigin, transformOrigin, children }: Props) {
2929
onMouseEnter={handlePopoverOpen}
3030
onMouseLeave={handlePopoverClose}
3131
data-test='helpIcon'
32+
color='primary'
3233
/>
3334
<Popover
3435
id='help-popover'

Diff for: frontend/src/entry/settings/EntryAccessInput.tsx

+13-1
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { useListUsers } from 'actions/user'
33
import { debounce } from 'lodash-es'
44
import { SyntheticEvent, useCallback, useEffect, useMemo, useState } from 'react'
55
import EntityItem from 'src/entry/settings/EntityItem'
6+
import ManualEntityInput from 'src/entry/settings/ManualEntityInput'
67
import MessageAlert from 'src/MessageAlert'
7-
import { CollaboratorEntry, EntityObject, EntryKindKeys, Role } from 'types/types'
8+
import { CollaboratorEntry, EntityKind, EntityObject, EntryKindKeys, Role } from 'types/types'
89
import { toSentenceCase } from 'utils/stringUtils'
910

1011
type EntryAccessInputProps = {
@@ -27,6 +28,7 @@ export default function EntryAccessInput({ value, onUpdate, entryKind, entryRole
2728
const [open, setOpen] = useState(false)
2829
const [accessList, setAccessList] = useState<CollaboratorEntry[]>(value)
2930
const [userListQuery, setUserListQuery] = useState('')
31+
const [manualEntityInputErrorMessage, setManualEntityInputErrorMessage] = useState('')
3032

3133
const { users, isUsersLoading, isUsersError } = useListUsers(userListQuery)
3234

@@ -71,6 +73,15 @@ export default function EntryAccessInput({ value, onUpdate, entryKind, entryRole
7173
setUserListQuery(value)
7274
}, [])
7375

76+
const handleAddEntityManually = (manualEntityName: string) => {
77+
setManualEntityInputErrorMessage('')
78+
if (accessList.find((collaborator) => collaborator.entity === `${EntityKind.USER}:${manualEntityName}`)) {
79+
setManualEntityInputErrorMessage('User has already been added below.')
80+
} else {
81+
setAccessList([...accessList, { entity: `${EntityKind.USER}:${manualEntityName}`, roles: [] }])
82+
}
83+
}
84+
7485
const debounceOnInputChange = debounce((event: SyntheticEvent<Element, Event>, value: string) => {
7586
handleInputChange(event, value)
7687
}, 500)
@@ -113,6 +124,7 @@ export default function EntryAccessInput({ value, onUpdate, entryKind, entryRole
113124
/>
114125
)}
115126
/>
127+
<ManualEntityInput onAddEntityManually={handleAddEntityManually} errorMessage={manualEntityInputErrorMessage} />
116128
<Table>
117129
<TableHead>
118130
<TableRow>

Diff for: frontend/src/entry/settings/ManualEntityInput.tsx

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
2+
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Stack, TextField, Typography } from '@mui/material'
3+
import { useGetUiConfig } from 'actions/uiConfig'
4+
import { FormEvent, useState } from 'react'
5+
import HelpPopover from 'src/common/HelpPopover'
6+
import Loading from 'src/common/Loading'
7+
import MessageAlert from 'src/MessageAlert'
8+
9+
interface ManualEntityInputProps {
10+
onAddEntityManually: (entityName: string) => void
11+
errorMessage: string
12+
}
13+
14+
export default function ManualEntityInput({ onAddEntityManually, errorMessage }: ManualEntityInputProps) {
15+
const [manualEntityName, setManualEntityName] = useState('')
16+
17+
const { uiConfig, isUiConfigLoading, isUiConfigError } = useGetUiConfig()
18+
19+
const handleAddEntityManuallyOnClick = (event: FormEvent<HTMLFormElement>) => {
20+
event.preventDefault()
21+
if (manualEntityName !== undefined && manualEntityName !== '') {
22+
setManualEntityName('')
23+
onAddEntityManually(manualEntityName)
24+
}
25+
}
26+
27+
if (isUiConfigError) {
28+
return <MessageAlert message={isUiConfigError.info.message} severity='error' />
29+
}
30+
31+
return (
32+
<Accordion sx={{ borderTop: 'none' }}>
33+
<AccordionSummary
34+
sx={{ pl: 0, borderTop: 'none' }}
35+
expandIcon={<ExpandMoreIcon />}
36+
aria-controls='manual-user-add-content'
37+
id='manual-user-add-header'
38+
>
39+
<Typography sx={{ mr: 1 }} component='caption'>
40+
Trouble finding a user? Click here to add them manually
41+
</Typography>
42+
</AccordionSummary>
43+
<AccordionDetails sx={{ p: 0 }}>
44+
{isUiConfigLoading && <Loading />}
45+
{!isUiConfigLoading && uiConfig && (
46+
<Box component='form' onSubmit={handleAddEntityManuallyOnClick}>
47+
<Stack spacing={2} direction={{ xs: 'column', sm: 'row' }} alignItems='center'>
48+
<TextField
49+
size='small'
50+
fullWidth
51+
label='User'
52+
value={manualEntityName}
53+
onChange={(e) => setManualEntityName(e.target.value)}
54+
/>
55+
{uiConfig.helpPopoverText.manualEntryAccess && (
56+
<HelpPopover>{uiConfig.helpPopoverText.manualEntryAccess}</HelpPopover>
57+
)}
58+
<Button variant='contained' type='submit' disabled={manualEntityName === ''}>
59+
Add
60+
</Button>
61+
</Stack>
62+
</Box>
63+
)}
64+
<MessageAlert message={errorMessage} severity='error' />
65+
</AccordionDetails>
66+
</Accordion>
67+
)
68+
}

0 commit comments

Comments
 (0)