Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cf949ae
wip, initial endpoint to fetch all access requests
JRB66955 Nov 5, 2025
7823542
update findAccessRequest function to allow admins to get all accessRe…
JRB66955 Nov 6, 2025
3013b70
flatten if/else block and add more meaningful error msg
JRB66955 Nov 7, 2025
68e2694
Merge branch 'main' into dev/all-access-requests-for-admin-endpoint
JRB66955 Nov 7, 2025
9b3bf36
add initial tests for findAccessRequest func
JRB66955 Nov 7, 2025
fa88707
add tests for new getAccessRequests file
JRB66955 Nov 10, 2025
80f61ff
add back accidentally removed snapshot
JRB66955 Nov 10, 2025
6aa51aa
fix api routing issue, remove temp url to something more suitable whi…
JRB66955 Nov 11, 2025
5f643e0
re-order import to fix class extends value undefined error with authe…
JRB66955 Nov 11, 2025
1945c77
add additional mock getEntities func for auth
JRB66955 Nov 11, 2025
a7e992f
Merge branch 'main' into dev/all-access-requests-for-admin-endpoint
JRB66955 Nov 11, 2025
2affc33
wip commit, updating wrt to PR feedback, making endpoint more akin to…
JRB66955 Nov 12, 2025
f283092
update with new tests to reflect updated endpoint
JRB66955 Nov 17, 2025
26351ea
update one of the tests to exercise the full function, with added req…
JRB66955 Nov 17, 2025
85564c4
Merge branch 'main' into dev/all-access-requests-for-admin-endpoint
JRB66955 Nov 18, 2025
d21d80a
Merge branch 'main' into dev/all-access-requests-for-admin-endpoint
JRB66955 Nov 19, 2025
4da7e94
update with PR feedback, only include relevant ARs in auth connector,…
JRB66955 Nov 19, 2025
6640081
Merge branch 'main' into dev/all-access-requests-for-admin-endpoint
JRB66955 Nov 20, 2025
4703d4c
Slight tweak to new condition, try not to break python tests this time
JRB66955 Nov 20, 2025
84e76a4
rework of accessRequests, a new model/AR aggregation to loop/filter t…
JRB66955 Nov 21, 2025
3bc4252
Merge branch 'main' into dev/all-access-requests-for-admin-endpoint
JRB66955 Nov 24, 2025
43a4118
update accessRequest spec tests
JRB66955 Nov 24, 2025
8c636a9
tweak base auth check to work as intended, verify success status prop…
JRB66955 Nov 24, 2025
60d43a8
update filter/mine implementation, simplify and actually make work
JRB66955 Nov 24, 2025
0fad292
Merge branch 'main' into dev/all-access-requests-for-admin-endpoint
JRB66955 Nov 24, 2025
430e986
big update to the findAccessRequest endpoint, a new catch-all aggrega…
JRB66955 Nov 26, 2025
7c9c5b6
rename findAccessRequest -> findAccessRequests, trivially initially g…
JRB66955 Nov 26, 2025
adebb03
pr feedback, split the full stage aggregation into two to make return…
JRB66955 Nov 26, 2025
be65897
update tests with some example dummy data, shuffle spec snapshots to …
JRB66955 Nov 27, 2025
58ac0d5
Merge branch 'main' into dev/all-access-requests-for-admin-endpoint
JRB66955 Nov 27, 2025
0665eda
update with pr feedback, add isNamed condition
JRB66955 Nov 28, 2025
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
11 changes: 11 additions & 0 deletions backend/src/connectors/authorisation/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,17 @@ export class BasicAuthorisationConnector {
return { success: false, info: 'You cannot change an access request you do not own.', id: request.id }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This authorisation function doesn't seem to be getting run for the search for some reason...?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False alarm, a problem with my local instance

}

if (
!(await this.model(user, model, ModelAction.View)).success &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to update this to deny a user if they are viewing and access request and either they aren't able to view a model or they aren't named on the access request

([AccessRequestAction.View] as AccessRequestActionKeys[]).includes(action)
) {
return {
success: false,
info: 'You cannot view an access request for a model you cannot view.',
id: request.id,
}
}

// Otherwise they either own the model, access request or this is a read-only action.
return { success: true, id: request.id }
}),
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { putFileScan } from './routes/v2/filescanning/putFileScan.js'
import { deleteAccessRequest } from './routes/v2/model/accessRequest/deleteAccessRequest.js'
import { getAccessRequest } from './routes/v2/model/accessRequest/getAccessRequest.js'
import { getAccessRequestCurrentUserPermissions } from './routes/v2/model/accessRequest/getAccessRequestCurrentUserPermissions.js'
import { getAccessRequests } from './routes/v2/model/accessRequest/getAccessRequests.js'
import { getModelAccessRequests } from './routes/v2/model/accessRequest/getModelAccessRequests.js'
import { patchAccessRequest } from './routes/v2/model/accessRequest/patchAccessRequest.js'
import { postAccessRequest } from './routes/v2/model/accessRequest/postAccessRequest.js'
Expand Down Expand Up @@ -140,6 +141,7 @@ server.post('/api/v2/model/:modelId/release/:semver/review', ...postReleaseRevie

server.post('/api/v2/model/:modelId/access-requests', ...postAccessRequest)
server.get('/api/v2/model/:modelId/access-requests', getModelAccessRequests)
server.get('/api/v2/access-requests/search', getAccessRequests)
server.get('/api/v2/model/:modelId/access-request/:accessRequestId', ...getAccessRequest)
server.delete('/api/v2/model/:modelId/access-request/:accessRequestId', ...deleteAccessRequest)
server.patch('/api/v2/model/:modelId/access-request/:accessRequestId', ...patchAccessRequest)
Expand Down
57 changes: 57 additions & 0 deletions backend/src/routes/v2/model/accessRequest/getAccessRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Request, Response } from 'express'
import { z } from 'zod'

import { AuditInfo } from '../../../../connectors/audit/Base.js'
import audit from '../../../../connectors/audit/index.js'
import { AccessRequestInterface } from '../../../../models/AccessRequest.js'
import { findAccessRequests } from '../../../../services/accessRequest.js'
import { accessRequestInterfaceSchema, registerPath } from '../../../../services/specification.js'
import { coerceArray, parse, strictCoerceBoolean } from '../../../../utils/validate.js'

export const GetAccessRequestsSchema = z.object({
query: z.object({
modelId: coerceArray(z.array(z.string()).optional().default([])),
schemaId: z.string().optional().default(''),
mine: strictCoerceBoolean(z.boolean().optional().default(false)),
adminAccess: strictCoerceBoolean(z.boolean().optional().default(false)),
}),
})

registerPath({
method: 'get',
path: '/api/v2/access-requests/search',
tags: ['access-request'],
description: 'Get all access requests for all models.',
schema: GetAccessRequestsSchema,
responses: {
200: {
description: 'An array of access request instances.',
content: {
'application/json': {
schema: z.object({
accessRequests: z.array(accessRequestInterfaceSchema),
}),
},
},
},
},
})

interface GetAccessRequestsResponse {
accessRequests: Array<AccessRequestInterface>
}

export const getAccessRequests = [
async (req: Request, res: Response<GetAccessRequestsResponse>): Promise<void> => {
req.audit = AuditInfo.ViewAccessRequests
const {
query: { modelId, schemaId, mine, adminAccess },
} = parse(req, GetAccessRequestsSchema)

const accessRequests = await findAccessRequests(req.user, modelId, schemaId, mine, adminAccess)

await audit.onViewAccessRequests(req, accessRequests)

res.json({ accessRequests })
},
]
74 changes: 72 additions & 2 deletions backend/src/services/accessRequest.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// eslint-disable-next-line simple-import-sort/imports
import { Validator } from 'jsonschema'
import { Types } from 'mongoose'
import { PipelineStage, Types } from 'mongoose'

import authentication from '../connectors/authentication/index.js'
import { Roles } from '../connectors/authentication/Base.js'
import { AccessRequestAction } from '../connectors/authorisation/actions.js'
import authorisation from '../connectors/authorisation/index.js'
import { AccessRequestInterface } from '../models/AccessRequest.js'
import AccessRequestModel, { AccessRequestDoc, AccessRequestInterface } from '../models/AccessRequest.js'
import AccessRequest from '../models/AccessRequest.js'
import ResponseModel, { ResponseKind } from '../models/Response.js'
import ReviewModel from '../models/Review.js'
Expand Down Expand Up @@ -131,6 +133,74 @@ export async function getAccessRequestById(user: UserInterface, accessRequestId:
return accessRequest
}

export async function findAccessRequests(
user: UserInterface,
modelId: Array<string>,
schemaId: string,
mine: boolean,
adminAccess?: boolean,
): Promise<Array<AccessRequestDoc>> {
if (adminAccess) {
if (!(await authentication.hasRole(user, Roles.Admin))) {
throw Forbidden('You do not have the required role.', {
userDn: user.dn,
requiredRole: Roles.Admin,
})
}
}

const query: any = {}

if (modelId.length) {
query.modelId = { $in: modelId }
}

if (schemaId) {
query.schemaId = { $in: schemaId }
}

if (mine) {
query['metadata.overview.entities'] = {
$in: await authentication.getEntities(user),
}
}

const stages: PipelineStage[] = [{ $match: query }]

//Auth already checked, so just need to check if they require admin access
if (adminAccess) {
const results = await AccessRequestModel.aggregate(stages)
return results
}

stages.push(
{ $group: { _id: '$modelId', accessRequests: { $push: '$$ROOT' } } },
{
$lookup: {
from: 'v2_models',
localField: '_id',
foreignField: 'id',
as: 'model',
},
},
{
$unwind: '$model',
},
)

const results = await AccessRequestModel.aggregate(stages)

const accessRequests: AccessRequestDoc[] = []
for (const result of results) {
const auth = await authorisation.accessRequests(user, result.model, result.accessRequests, AccessRequestAction.View)

const authorisedIds = new Set(auth.filter((result) => result.success).map((result) => result.id))
const filteredAccessRequests = result.accessRequests.filter((accessRequest) => authorisedIds.has(accessRequest.id))
accessRequests.push(...filteredAccessRequests)
}
return accessRequests
}

export type UpdateAccessRequestParams = Pick<AccessRequestInterface, 'metadata'>
export async function updateAccessRequest(
user: UserInterface,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`routes > accessRequest > getAccessRequests > 200 > ok 1`] = `
{
"accessRequests": {
"_id": "664e1aa8bda1f88c28e1c0ce",
"deleted": false,
"deletedAt": "",
"deletedBy": "",
"id": "test-access-request-13623",
"modelId": "test-model-4342",
},
}
`;

exports[`routes > accessRequest > getAccessRequests > audit > expected call 1`] = `
{
"_id": "664e1aa8bda1f88c28e1c0ce",
"deleted": false,
"deletedAt": "",
"deletedBy": "",
"id": "test-access-request-13623",
"modelId": "test-model-4342",
}
`;
51 changes: 51 additions & 0 deletions backend/test/routes/model/accessRequest/getAccessRequests.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import qs from 'qs'
import { describe, expect, test, vi } from 'vitest'

import audit from '../../../../src/connectors/audit/__mocks__/index.js'
import AccessRequestModel from '../../../../src/models/AccessRequest.js'
import { GetAccessRequestsSchema } from '../../../../src/routes/v2/model/accessRequest/getAccessRequests.js'
import { createFixture, testGet } from '../../../testUtils/routes.js'
import { testAccessRequest } from '../../../testUtils/testModels.js'

vi.mock('../../../../src/connectors/audit/index.js')

const accessRequestsMock = vi.hoisted(() => {
return {
findAccessRequests: vi.fn(() => undefined as any),
}
})
vi.mock('../../../../src/services/accessRequest.js', () => accessRequestsMock)

const responseMock = vi.hoisted(() => {
return {
findResponses: vi.fn(() => undefined as any),
}
})
vi.mock('../../../../src/services/response.js', () => responseMock)

describe('routes > accessRequest > getAccessRequests', () => {
test('200 > ok', async () => {
const accessRequestDoc = new AccessRequestModel({ ...testAccessRequest })
accessRequestsMock.findAccessRequests.mockResolvedValueOnce(accessRequestDoc)
responseMock.findResponses.mockResolvedValue([testAccessRequest])

const fixture = createFixture(GetAccessRequestsSchema)
const res = await testGet(`/api/v2/access-requests/search?${qs.stringify(fixture.query)}`)

expect(res.statusCode).toBe(200)
expect(res.body).matchSnapshot()
})

test('audit > expected call', async () => {
const accessRequestDoc = new AccessRequestModel({ ...testAccessRequest })
accessRequestsMock.findAccessRequests.mockResolvedValueOnce(accessRequestDoc)
responseMock.findResponses.mockResolvedValue([testAccessRequest])

const fixture = createFixture(GetAccessRequestsSchema)
const res = await testGet(`/api/v2/access-requests/search?${qs.stringify(fixture.query)}`)

expect(res.statusCode).toBe(200)
expect(audit.onViewAccessRequests).toBeCalled()
expect(audit.onViewAccessRequests.mock.calls.at(0)?.at(1)).toMatchSnapshot()
})
})
28 changes: 28 additions & 0 deletions backend/test/services/__snapshots__/accessRequest.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`services > accessRequest > findAccessRequests > admin access with auth 1`] = `
[
{
"accessRequests": [
{
"id": "a",
},
],
},
]
`;

exports[`services > accessRequest > findAccessRequests > all filters 1`] = `
[
{
"id": "a",
},
]
`;

exports[`services > accessRequest > findAccessRequests > no filters 1`] = `
[
{
"id": "a",
},
]
`;

exports[`services > accessRequest > getAccessRequestsByModel > good 1`] = `
[
{
Expand Down
Loading
Loading