Skip to content

Commit cc06751

Browse files
authored
Merge pull request #1293 from joshunrau/audit-log
add audit logging
2 parents 4b6d43b + fef6c05 commit cc06751

File tree

22 files changed

+580
-42
lines changed

22 files changed

+580
-42
lines changed

apps/api/prisma/schema.prisma

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,36 @@ type EncryptionKeyPair {
2727
privateKey Bytes
2828
}
2929

30+
enum AuditLogAction {
31+
CREATE
32+
UPDATE
33+
DELETE
34+
LOGIN
35+
}
36+
37+
enum AuditLogEntity {
38+
ASSIGNMENT
39+
GROUP
40+
INSTRUMENT_RECORD
41+
INSTRUMENT
42+
SUBJECT
43+
SESSION
44+
USER
45+
}
46+
47+
model AuditLog {
48+
id String @id @default(auto()) @map("_id") @db.ObjectId
49+
action AuditLogAction
50+
entity AuditLogEntity
51+
group Group? @relation(fields: [groupId], references: [id])
52+
groupId String? @db.ObjectId
53+
timestamp Int
54+
user User? @relation(fields: [userId], references: [id])
55+
userId String? @db.ObjectId
56+
57+
@@map("AuditLogModel")
58+
}
59+
3060
model Assignment {
3161
createdAt DateTime @default(now()) @db.Date
3262
updatedAt DateTime @updatedAt @db.Date
@@ -68,7 +98,7 @@ type GroupSettings {
6898
defaultIdentificationMethod SubjectIdentificationMethod
6999
idValidationRegex String?
70100
idValidationRegexErrorMessage ErrorMessage?
71-
subjectIdDisplayLength Int?
101+
subjectIdDisplayLength Int?
72102
}
73103

74104
model Group {
@@ -78,12 +108,13 @@ model Group {
78108
accessibleInstrumentIds String[]
79109
accessibleInstruments Instrument[] @relation(fields: [accessibleInstrumentIds], references: [id])
80110
assignments Assignment[]
111+
auditLogs AuditLog[]
81112
instrumentRecords InstrumentRecord[]
82113
name String @unique
83114
settings GroupSettings
84115
sessions Session[]
85116
subjects Subject[] @relation(fields: [subjectIds], references: [id])
86-
subjectIds String[]
117+
subjectIds String[]
87118
type GroupType
88119
userIds String[] @db.ObjectId
89120
users User[] @relation(fields: [userIds], references: [id])
@@ -199,6 +230,7 @@ model User {
199230
createdAt DateTime @default(now()) @db.Date
200231
updatedAt DateTime @updatedAt @db.Date
201232
id String @id @default(auto()) @map("_id") @db.ObjectId
233+
auditLogs AuditLog[]
202234
basePermissionLevel BasePermissionLevel?
203235
additionalPermissions AuthRule[]
204236
firstName String
@@ -230,8 +262,8 @@ model Session {
230262
instrumentRecords InstrumentRecord[]
231263
subject Subject? @relation(fields: [subjectId], references: [id])
232264
subjectId String
233-
user User? @relation(fields: [userId], references: [id])
234-
userId String?
265+
user User? @relation(fields: [userId], references: [id])
266+
userId String? @db.ObjectId
235267
type SessionType
236268
237269
@@map("SessionModel")

apps/api/src/assignments/assignments.controller.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CurrentUser } from '@douglasneuroinformatics/libnest';
2+
import type { RequestUser } from '@douglasneuroinformatics/libnest';
23
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
34
import { ApiOperation } from '@nestjs/swagger';
45
import type { Assignment } from '@opendatacapture/schemas/assignment';
@@ -17,8 +18,8 @@ export class AssignmentsController {
1718
@ApiOperation({ summary: 'Create Assignment' })
1819
@Post()
1920
@RouteAccess({ action: 'create', subject: 'Assignment' })
20-
create(@Body() data: CreateAssignmentDto): Promise<Assignment> {
21-
return this.assignmentsService.create(data);
21+
create(@Body() data: CreateAssignmentDto, @CurrentUser() currentUser: RequestUser): Promise<Assignment> {
22+
return this.assignmentsService.create(data, currentUser);
2223
}
2324

2425
@ApiOperation({ summary: 'Get All Assignments' })
@@ -31,7 +32,7 @@ export class AssignmentsController {
3132
@ApiOperation({ summary: 'Update Assignment' })
3233
@Patch(':id')
3334
@RouteAccess({ action: 'update', subject: 'Assignment' })
34-
updateById(@Param('id') id: string, @Body() data: UpdateAssignmentDto, @CurrentUser('ability') ability?: AppAbility) {
35-
return this.assignmentsService.updateById(id, data, { ability });
35+
updateById(@Param('id') id: string, @Body() data: UpdateAssignmentDto, @CurrentUser() currentUser: RequestUser) {
36+
return this.assignmentsService.updateById(id, data, currentUser);
3637
}
3738
}

apps/api/src/assignments/assignments.service.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import crypto from 'node:crypto';
22

33
import { HybridCrypto } from '@douglasneuroinformatics/libcrypto';
44
import { ConfigService, InjectModel } from '@douglasneuroinformatics/libnest';
5-
import type { Model } from '@douglasneuroinformatics/libnest';
5+
import type { Model, RequestUser } from '@douglasneuroinformatics/libnest';
66
import { Injectable, NotFoundException } from '@nestjs/common';
77
import type { Assignment, UpdateAssignmentData } from '@opendatacapture/schemas/assignment';
88

9+
import { AuditLogger } from '@/audit/audit.logger';
910
import { accessibleQuery } from '@/auth/ability.utils';
1011
import type { EntityOperationOptions } from '@/core/types';
1112
import { GatewayService } from '@/gateway/gateway.service';
@@ -19,6 +20,7 @@ export class AssignmentsService {
1920
constructor(
2021
@InjectModel('Assignment') private readonly assignmentModel: Model<'Assignment'>,
2122
configService: ConfigService,
23+
private readonly auditLogger: AuditLogger,
2224
private readonly gatewayService: GatewayService
2325
) {
2426
if (configService.get('NODE_ENV') === 'production') {
@@ -30,7 +32,10 @@ export class AssignmentsService {
3032
}
3133
}
3234

33-
async create({ expiresAt, groupId, instrumentId, subjectId }: CreateAssignmentDto): Promise<Assignment> {
35+
async create(
36+
{ expiresAt, groupId, instrumentId, subjectId }: CreateAssignmentDto,
37+
currentUser: RequestUser
38+
): Promise<Assignment> {
3439
const { privateKey, publicKey } = await HybridCrypto.generateKeyPair();
3540
const id = crypto.randomUUID();
3641
const assignment = await this.assignmentModel.create({
@@ -68,6 +73,7 @@ export class AssignmentsService {
6873
await this.assignmentModel.delete({ where: { id } });
6974
throw err;
7075
}
76+
await this.auditLogger.log('CREATE', 'ASSIGNMENT', { groupId: groupId ?? null, userId: currentUser.id });
7177
return assignment;
7278
}
7379

@@ -92,13 +98,27 @@ export class AssignmentsService {
9298
return assignment;
9399
}
94100

95-
async updateById(id: string, data: UpdateAssignmentData, { ability }: EntityOperationOptions = {}) {
101+
async updateById(id: string, data: UpdateAssignmentData, currentUser: RequestUser) {
96102
if (data.status === 'CANCELED') {
97103
await this.gatewayService.deleteRemoteAssignment(id);
98104
}
99-
return this.assignmentModel.update({
105+
const assignment = await this.assignmentModel.update({
100106
data,
101-
where: { AND: [accessibleQuery(ability, 'update', 'Assignment')], id }
107+
where: { AND: [accessibleQuery(currentUser.ability, 'update', 'Assignment')], id }
108+
});
109+
await this.auditLogger.log('UPDATE', 'ASSIGNMENT', { groupId: assignment.groupId, userId: currentUser.id });
110+
return assignment;
111+
}
112+
113+
/** used by the gateway internal system */
114+
async updateStatusById(id: string, status: UpdateAssignmentData['status']) {
115+
return this.assignmentModel.update({
116+
data: {
117+
status
118+
},
119+
where: {
120+
id
121+
}
102122
});
103123
}
104124
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ParseSchemaPipe } from '@douglasneuroinformatics/libnest';
2+
import { Controller, Get, Query } from '@nestjs/common';
3+
import { $AuditLogsQuerySearchParams } from '@opendatacapture/schemas/audit';
4+
import type { $AuditLog } from '@opendatacapture/schemas/audit';
5+
import { z } from 'zod/v4';
6+
7+
import { RouteAccess } from '@/core/decorators/route-access.decorator';
8+
9+
import { AuditService } from './audit.service';
10+
11+
@Controller('audit')
12+
export class AuditController {
13+
constructor(private readonly auditService: AuditService) {}
14+
15+
@Get('logs')
16+
@RouteAccess({ action: 'manage', subject: 'all' })
17+
find(
18+
@Query(new ParseSchemaPipe({ schema: $AuditLogsQuerySearchParams }))
19+
query: z.infer<typeof $AuditLogsQuerySearchParams>
20+
): Promise<$AuditLog[]> {
21+
return this.auditService.find(query);
22+
}
23+
}

apps/api/src/audit/audit.logger.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { InjectModel } from '@douglasneuroinformatics/libnest';
2+
import type { Model } from '@douglasneuroinformatics/libnest';
3+
import { Injectable } from '@nestjs/common';
4+
import type { $AuditLogAction, $AuditLogEntity } from '@opendatacapture/schemas/audit';
5+
6+
@Injectable()
7+
export class AuditLogger {
8+
constructor(@InjectModel('AuditLog') private readonly auditLogModel: Model<'AuditLog'>) {}
9+
10+
async log(
11+
action: $AuditLogAction,
12+
entity: $AuditLogEntity,
13+
params: { groupId: null | string; userId: string }
14+
): Promise<void> {
15+
await this.auditLogModel.create({
16+
data: {
17+
action,
18+
entity,
19+
group: params.groupId ? { connect: { id: params.groupId } } : undefined,
20+
timestamp: Date.now(),
21+
user: {
22+
connect: {
23+
id: params.userId
24+
}
25+
}
26+
}
27+
});
28+
}
29+
}

apps/api/src/audit/audit.module.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Global, Module } from '@nestjs/common';
2+
3+
import { AuditController } from './audit.controller';
4+
import { AuditLogger } from './audit.logger';
5+
import { AuditService } from './audit.service';
6+
7+
@Global()
8+
@Module({
9+
controllers: [AuditController],
10+
exports: [AuditLogger],
11+
providers: [AuditLogger, AuditService]
12+
})
13+
export class AuditModule {}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { InjectModel } from '@douglasneuroinformatics/libnest';
2+
import type { Model } from '@douglasneuroinformatics/libnest';
3+
import { Injectable } from '@nestjs/common';
4+
import type { $AuditLog, $AuditLogsQuerySearchParams } from '@opendatacapture/schemas/audit';
5+
6+
@Injectable()
7+
export class AuditService {
8+
constructor(@InjectModel('AuditLog') private readonly auditLogModel: Model<'AuditLog'>) {}
9+
10+
async find({ action, entity, groupId, userId }: $AuditLogsQuerySearchParams): Promise<$AuditLog[]> {
11+
return this.auditLogModel.findMany({
12+
select: {
13+
action: true,
14+
entity: true,
15+
group: { select: { name: true } },
16+
id: true,
17+
timestamp: true,
18+
user: { select: { username: true } }
19+
},
20+
where: {
21+
action,
22+
entity,
23+
groupId,
24+
userId
25+
}
26+
});
27+
}
28+
}

apps/api/src/auth/auth.service.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { JwtService } from '@nestjs/jwt';
55
import type { $LoginCredentials, TokenPayload } from '@opendatacapture/schemas/auth';
66
import type { Group, User } from '@prisma/client';
77

8+
import { AuditLogger } from '@/audit/audit.logger';
89
import { UsersService } from '@/users/users.service';
910

1011
import { AbilityFactory } from './ability.factory';
@@ -13,6 +14,7 @@ import { AbilityFactory } from './ability.factory';
1314
export class AuthService {
1415
constructor(
1516
private readonly abilityFactory: AbilityFactory,
17+
private readonly auditLogger: AuditLogger,
1618
private readonly cryptoService: CryptoService,
1719
private readonly jwtService: JwtService,
1820
private readonly usersService: UsersService
@@ -52,22 +54,23 @@ export class AuthService {
5254
basePermissionLevel: user.basePermissionLevel,
5355
firstName: user.firstName,
5456
groups: user.groups,
57+
id: user.id,
5558
lastName: user.lastName,
5659
username: user.username
5760
};
5861

5962
const ability = this.abilityFactory.createForPayload(tokenPayload);
6063

61-
return {
62-
accessToken: await this.jwtService.signAsync(
63-
{
64-
...tokenPayload,
65-
permissions: ability.rules
66-
},
67-
{
68-
expiresIn: '1h'
69-
}
70-
)
71-
};
64+
const accessToken = await this.jwtService.signAsync(
65+
{ ...tokenPayload, permissions: ability.rules },
66+
{ expiresIn: '1h' }
67+
);
68+
69+
await this.auditLogger.log('LOGIN', 'USER', {
70+
groupId: null,
71+
userId: user.id
72+
});
73+
74+
return { accessToken };
7275
}
7376
}

apps/api/src/gateway/gateway.synchronizer.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,7 @@ export class GatewaySynchronizer implements OnApplicationBootstrap {
162162
} else {
163163
await this.gatewayService.deleteRemoteAssignment(assignment.id);
164164
}
165-
await this.assignmentsService.updateById(assignment.id, {
166-
status: assignment.status
167-
});
165+
await this.assignmentsService.updateStatusById(assignment.id, assignment.status);
168166
}
169167
this.loggingService.log('Done synchronizing with gateway');
170168
}

apps/api/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AppFactory, PrismaModule } from '@douglasneuroinformatics/libnest';
22

33
import { AssignmentsModule } from './assignments/assignments.module';
4+
import { AuditModule } from './audit/audit.module';
45
import { AuthModule } from './auth/auth.module';
56
import { PrismaModuleOptionsFactory } from './core/prisma';
67
import { $Env } from './core/schemas/env.schema';
@@ -37,6 +38,7 @@ export default AppFactory.create({
3738
},
3839
envSchema: $Env,
3940
imports: [
41+
AuditModule,
4042
AuthModule,
4143
GroupsModule,
4244
InstrumentRecordsModule,

0 commit comments

Comments
 (0)