Skip to content
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
31 changes: 16 additions & 15 deletions .agents/docs/architecture/auth-and-permissions.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ enum AuditLogAction {
DELETE
LOGIN
SEND_EMAIL
ARCHIVE
UNARCHIVE
}

enum AuditLogEntity {
Expand Down Expand Up @@ -223,6 +225,8 @@ model Instrument {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @map("_id")
// Set when an administrator retires a series from new sessions and assignments; null while active.
archivedAt DateTime? @db.Date
assignments Assignment[]
bundle String
groups Group[] @relation(fields: [groupIds], references: [id])
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/assignments/__tests__/assignments.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe('AssignmentsService', () => {
let assignmentsService: AssignmentsService;
let assignmentModel: MockedInstance<Model<'Assignment'>>;
let groupModel: MockedInstance<Model<'Group'>>;
let instrumentModel: MockedInstance<Model<'Instrument'>>;
let subjectModel: MockedInstance<Model<'Subject'>>;
let auditLogger: MockedInstance<AuditLogger>;
let gatewayService: MockedInstance<GatewayService>;
Expand All @@ -86,6 +87,7 @@ describe('AssignmentsService', () => {
AssignmentsService,
MockFactory.createForModelToken(getModelToken('Assignment')),
MockFactory.createForModelToken(getModelToken('Group')),
MockFactory.createForModelToken(getModelToken('Instrument')),
MockFactory.createForModelToken(getModelToken('Subject')),
{ provide: AuditLogger, useValue: { log: vi.fn() } },
{ provide: ConfigService, useValue: { get: () => 3500, getOrThrow: () => ({ origin: 'https://x' }) } },
Expand All @@ -103,6 +105,7 @@ describe('AssignmentsService', () => {

assignmentModel = moduleRef.get(getModelToken('Assignment'));
groupModel = moduleRef.get(getModelToken('Group'));
instrumentModel = moduleRef.get(getModelToken('Instrument'));
subjectModel = moduleRef.get(getModelToken('Subject'));
auditLogger = moduleRef.get(AuditLogger);
gatewayService = moduleRef.get(GatewayService);
Expand All @@ -111,6 +114,7 @@ describe('AssignmentsService', () => {
groupModel.findFirst.mockResolvedValue({ accessibleInstrumentIds: ['instrument-1', 'instrument-2'], id: GROUP_ID });
subjectModel.findMany.mockResolvedValue([{ id: 'subject-1' }, { id: 'subject-2' }]);
assignmentModel.findMany.mockResolvedValue([]);
instrumentModel.findMany.mockResolvedValue([]);
assignmentModel.create.mockImplementation(({ data }: any) =>
Promise.resolve({ ...data, instrumentId: 'instrument-1' })
);
Expand Down Expand Up @@ -155,6 +159,14 @@ describe('AssignmentsService', () => {
expect(failure.issues).toContainEqual({ instrumentIds: ['instrument-other'], kind: 'INSTRUMENT_UNAVAILABLE' });
});

// An archived series stays on the group's opt-in list so unarchiving restores it, so that list
// alone would still admit it.
it('should refuse an archived series the group has opted into, since archiving retires it from new assignments', async () => {
instrumentModel.findMany.mockResolvedValueOnce([{ id: 'instrument-1' }]);
const failure = await failureOf(assignmentsService.bulkPreflight(request(), permissiveUser()));
expect(failure.issues).toContainEqual({ instrumentIds: ['instrument-1'], kind: 'INSTRUMENT_UNAVAILABLE' });
});

it('should restrict subjects to the selected group and the caller ability', async () => {
await assignmentsService.bulkPreflight(request(), permissiveUser());
expect(subjectModel.findMany.mock.lastCall?.[0]).toMatchObject({
Expand Down Expand Up @@ -269,6 +281,13 @@ describe('AssignmentsService', () => {
expect(gatewayService.createRemoteAssignment).toHaveBeenCalledTimes(1);
});

it('should refuse an administrator an ungrouped assignment of an archived series, which skips the group checks', async () => {
instrumentModel.findMany.mockResolvedValueOnce([{ id: 'instrument-1' }]);
const failure = await failureOf(assignmentsService.create({ ...data(), groupId: undefined }, userAt('ADMIN')));
expect(failure.issues).toContainEqual({ instrumentIds: ['instrument-1'], kind: 'INSTRUMENT_UNAVAILABLE' });
expect(assignmentModel.create).not.toHaveBeenCalled();
});

it('should file a grouped assignment under the group it names, so that group can find and cancel it', async () => {
await assignmentsService.create(data(), userAt('GROUP_MANAGER'));
expect(assignmentModel.create.mock.lastCall?.[0].data.group).toStrictEqual({ connect: { id: GROUP_ID } });
Expand Down
26 changes: 24 additions & 2 deletions apps/api/src/assignments/assignments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class AssignmentsService {
constructor(
@InjectModel('Assignment') private readonly assignmentModel: Model<'Assignment'>,
@InjectModel('Group') private readonly groupModel: Model<'Group'>,
@InjectModel('Instrument') private readonly instrumentModel: Model<'Instrument'>,
@InjectModel('Subject') private readonly subjectModel: Model<'Subject'>,
configService: ConfigService,
private readonly auditLogger: AuditLogger,
Expand Down Expand Up @@ -84,6 +85,11 @@ export class AssignmentsService {
);
} else if (!currentUser.ability.can('create', forcedAppSubject('Assignment', { groupId: null }))) {
throw new ForbiddenException('Insufficient permissions to create an assignment outside a group');
} else if ((await this.findArchivedInstrumentIds([instrumentId])).length > 0) {
throw new UnprocessableEntityException({
code: 'BULK_ASSIGNMENT_REFUSED',
issues: [{ instrumentIds: [instrumentId], kind: 'INSTRUMENT_UNAVAILABLE' }]
} satisfies BulkAssignmentFailure);
}
const { assignment, publicKey } = await this.stageAssignment({ expiresAt, groupId, instrumentId, subjectId });
try {
Expand Down Expand Up @@ -231,6 +237,18 @@ export class AssignmentsService {
}
}

/**
* Which of the given instruments an administrator has archived. Unscoped by the caller's ability:
* it only narrows ids the caller already named, and every other check on them is made separately.
*/
private async findArchivedInstrumentIds(instrumentIds: string[]): Promise<string[]> {
const archived = await this.instrumentModel.findMany({
select: { id: true },
where: { archivedAt: { not: null }, id: { in: instrumentIds } }
});
return archived.map(({ id }) => id);
}

/**
* Every authorization and validity check a grouped assignment depends on, in one place so
* preflight, bulk create and single create cannot drift apart. Throws with all issues attached;
Expand All @@ -254,10 +272,14 @@ export class AssignmentsService {
const issues: BulkAssignmentIssue[] = [];

// The group's own opt-in list is the authority: an instrument existing is not permission to
// assign it here.
// assign it here. An archived series stays on that list, so it can be unarchived without every
// group opting back in, and is refused separately.
const accessibleInstrumentIds = new Set(group.accessibleInstrumentIds);
const instrumentIds = timepoints.map(({ instrumentId }) => instrumentId);
const unavailableInstrumentIds = instrumentIds.filter((id) => !accessibleInstrumentIds.has(id));
const archivedInstrumentIds = new Set(await this.findArchivedInstrumentIds(instrumentIds));
const unavailableInstrumentIds = [
...new Set(instrumentIds.filter((id) => !accessibleInstrumentIds.has(id) || archivedInstrumentIds.has(id)))
];
if (unavailableInstrumentIds.length > 0) {
issues.push({ instrumentIds: unavailableInstrumentIds, kind: 'INSTRUMENT_UNAVAILABLE' });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { LoggingService } from '@douglasneuroinformatics/libnest';
import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import type { Group } from '@opendatacapture/schemas/group';
import type { BasePermissionLevel } from '@opendatacapture/schemas/user';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { AbilityFactory } from '@/auth/ability.factory';
import { ACCEPTS_INSTRUMENT_TOKEN_METADATA_KEY } from '@/core/decorators/accepts-instrument-token.decorator';
import { ROUTE_ACCESS_METADATA_KEY } from '@/core/decorators/route-access.decorator';
import type { ProtectedRoutePermissionSet } from '@/core/decorators/route-access.decorator';

import { InstrumentsController } from '../instruments.controller';
import { InstrumentsService } from '../instruments.service';
Expand Down Expand Up @@ -53,4 +59,41 @@ describe('InstrumentsController', () => {
);
expect(accepting).toEqual(['create']);
});

// Every group's series, and retiring one, belong to administrators alone: a group manager may create
// and delete their own group's series, which the guard would not tell apart from any other.
describe.each(['findSeriesOverview', 'updateSeriesArchive'] as const)('route access for %s', (handlerName) => {
const abilityFor = (basePermissionLevel: BasePermissionLevel) =>
new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload({
basePermissionLevel,
firstName: 'Test',
groups: [{ id: 'group-1' }] as Group[],
id: 'user-1',
kind: 'login',
lastName: 'User',
mustResetPassword: false,
username: 'test-user'
});
const { action, subject } = new Reflector().get<ProtectedRoutePermissionSet>(
ROUTE_ACCESS_METADATA_KEY,
Object.getOwnPropertyDescriptor(InstrumentsController.prototype, handlerName)!.value
);

it('should refuse a group manager, who may otherwise create and delete their own series', () => {
expect(abilityFor('GROUP_MANAGER').can(action, subject)).toBe(false);
});

it('should allow an administrator', () => {
expect(abilityFor('ADMIN').can(action, subject)).toBe(true);
});
});

it('should hand the caller ability to the series overview, so the group lookup stays scoped', async () => {
const ability = { can: vi.fn(() => true) } as any;
instrumentsService.findSeriesOverview.mockResolvedValue([]);

await instrumentsController.findSeriesOverview(ability);

expect(instrumentsService.findSeriesOverview).toHaveBeenCalledWith({ ability });
});
});
Loading
Loading