Skip to content

Commit eee401e

Browse files
committed
Implement auditing functionality with history tracking and agent reference in the Audits module
1 parent 97ec5cd commit eee401e

File tree

9 files changed

+311
-1
lines changed

9 files changed

+311
-1
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import microdiff, { Difference } from "microdiff"
2+
import { Query, Schema, Model, Document, Types } from 'mongoose'
3+
import { Audits } from '~/core/audits/_schemas/audits.schema'
4+
import * as _ from 'radash'
5+
import { RequestContext } from "nestjs-request-context"
6+
import { Logger } from "@nestjs/common"
7+
8+
export const HISTORY_PLUGIN_BEFORE_KEY = '_auditBefore'
9+
10+
export type ChangesType = Difference & {
11+
type: "REMOVE" | "CHANGE" | "CREATE"
12+
path: string
13+
oldValue?: any
14+
value?: any
15+
}
16+
17+
export enum AuditOperation {
18+
INSERT = 'insert',
19+
UPDATE = 'update',
20+
DELETE = 'delete',
21+
REPLACE = 'replace',
22+
}
23+
24+
type QueryResultType<T> = T extends Query<infer ResultType, any> ? ResultType : never;
25+
26+
export interface HistoryPluginOptions {
27+
/**
28+
* The name of the MongoDB collection; defaults to the Mongoose model's collection name
29+
*/
30+
collectionName: string
31+
32+
/**
33+
* The Mongoose model name for audits; defaults to the Nest model name for Audits
34+
*/
35+
auditsModelName?: string
36+
37+
/**
38+
* Fields to ignore when determining if changes should be audited.
39+
* If only these fields changed, the audit event will be skipped.
40+
*/
41+
ignoredFields?: string[]
42+
}
43+
44+
function detectChanges<T = Query<any, any>>(
45+
before: QueryResultType<T> & { toObject?: Function },
46+
after: QueryResultType<T> & { toObject?: Function },
47+
options?: HistoryPluginOptions,
48+
): [boolean, ChangesType[]] {
49+
before = before ?? {} as any
50+
after = after ?? {} as any
51+
const ignoredFields = options?.ignoredFields || []
52+
53+
const beforeForComparison = JSON.parse(JSON.stringify(
54+
before?.toObject ? before.toObject() : before,
55+
))
56+
const afterForComparison = JSON.parse(JSON.stringify(
57+
after?.toObject ? after.toObject() : after,
58+
))
59+
60+
const diff = microdiff(beforeForComparison, afterForComparison)
61+
62+
const changes = diff.filter(change => {
63+
// Deal with nested ignored fields
64+
const key = change.path.join('.')
65+
for (const ignoredField of ignoredFields) {
66+
if (key === ignoredField || key.startsWith(ignoredField + '.')) {
67+
return false
68+
}
69+
}
70+
return true
71+
}).map(change => {
72+
return <ChangesType>{
73+
...change,
74+
path: change.path.join('.'),
75+
}
76+
})
77+
const hasChanged = changes.length > 0
78+
79+
return [hasChanged, changes]
80+
}
81+
82+
function resolveAgent(): any {
83+
const user = RequestContext.currentContext.req?.user
84+
85+
return {
86+
$ref: user.$ref ?? 'System',
87+
id: Types.ObjectId.createFromHexString(user._id ?? '000000000000000000000000'),
88+
name: user.username ?? 'console',
89+
}
90+
}
91+
92+
export function historyPlugin(schema: Schema, options: HistoryPluginOptions) {
93+
const defaultOptions = {
94+
auditsModelName: Audits.name,
95+
ignoredFields: ['metadata'],
96+
}
97+
const mergedOptions = {
98+
...defaultOptions,
99+
...options,
100+
ignoredFields: [...(defaultOptions.ignoredFields || []), ...(options.ignoredFields || [])],
101+
}
102+
103+
const logger = new Logger('HistoryPlugin')
104+
105+
schema.pre('save', async function () {
106+
if (this.isNew) {
107+
this.$locals[HISTORY_PLUGIN_BEFORE_KEY] = null
108+
return
109+
}
110+
111+
const before = await this.model().findById(this._id)
112+
Logger.verbose(`Audit before state: ${JSON.stringify(before)}`)
113+
this.$locals[HISTORY_PLUGIN_BEFORE_KEY] = before
114+
})
115+
116+
schema.post('save', async function (after: Document | null) {
117+
const before: Document | null = this.$locals[HISTORY_PLUGIN_BEFORE_KEY] as Document | null
118+
console.log('post save fired', before)
119+
const [hasChanged, changes] = detectChanges(before, after, mergedOptions)
120+
logger.verbose(`Audit after state: ${JSON.stringify(after)}`)
121+
122+
if (!hasChanged) {
123+
logger.debug(`No significant changes detected for ${mergedOptions.collectionName} ${after?._id ?? before?._id}, skipping audit log.`)
124+
return
125+
}
126+
127+
logger.log(`Creating audit log for ${mergedOptions.collectionName} ${after?._id ?? before?._id}`)
128+
const agent = resolveAgent()
129+
const AuditsModel: Model<any> = this.model(mergedOptions.auditsModelName!)
130+
await AuditsModel.create({
131+
coll: mergedOptions.collectionName,
132+
documentId: after?._id ?? before?._id,
133+
op: before ? AuditOperation.UPDATE : AuditOperation.INSERT,
134+
agent,
135+
data: after,
136+
changes,
137+
metadata: {
138+
'metadata.createdBy': agent.name || 'anonymous',
139+
'metadata.createdAt': new Date(),
140+
},
141+
})
142+
})
143+
144+
schema.pre('findOneAndUpdate', { query: true, document: false }, async function () {
145+
const before = await this.model.findOne(this.getFilter())
146+
logger.verbose(`Audit before state: ${JSON.stringify(before)}`)
147+
this.setOptions({ [HISTORY_PLUGIN_BEFORE_KEY]: before })
148+
})
149+
150+
schema.post('findOneAndUpdate', async function (this: Query<any, any>, after: Document | null) {
151+
const before: Document | null = this.getOptions()[HISTORY_PLUGIN_BEFORE_KEY]
152+
const [hasChanged, changes] = detectChanges(before, after, mergedOptions)
153+
logger.verbose(`Audit after state: ${JSON.stringify(after)}`)
154+
155+
if (!hasChanged) {
156+
logger.debug(`No significant changes detected for ${mergedOptions.collectionName} ${after?._id ?? before?._id}, skipping audit log.`)
157+
return
158+
}
159+
160+
logger.log(`Creating audit log for ${mergedOptions.collectionName} ${after?._id ?? before?._id}`)
161+
const agent = resolveAgent()
162+
const AuditsModel: Model<any> = this.model.db.model(mergedOptions.auditsModelName!)
163+
await AuditsModel.create({
164+
coll: mergedOptions.collectionName,
165+
documentId: after?._id ?? before?._id,
166+
op: before ? AuditOperation.UPDATE : AuditOperation.INSERT,
167+
agent,
168+
data: after,
169+
changes,
170+
metadata: {
171+
'metadata.createdBy': agent.name || 'anonymous',
172+
'metadata.createdAt': new Date(),
173+
},
174+
})
175+
})
176+
177+
schema.post('findOneAndDelete', async function (res: any) {
178+
//TODO: handle delete
179+
})
180+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2+
import { Document, Types } from 'mongoose';
3+
4+
@Schema({ _id: false })
5+
export class AgentPart extends Document {
6+
@Prop({ type: String, required: true })
7+
public $ref: string;
8+
9+
@Prop({ type: Types.ObjectId, required: true })
10+
public id: Types.ObjectId;
11+
12+
@Prop({ type: String })
13+
public name?: string;
14+
}
15+
16+
export const AgentPartSchema = SchemaFactory.createForClass(AgentPart);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2+
import { Document, Types } from 'mongoose';
3+
import { AbstractSchema } from '~/_common/abstracts/schemas/abstract.schema';
4+
import { AgentPart, AgentPartSchema } from './_parts/agent.parts.schema';
5+
import { ChangesType } from '~/_common/plugins/mongoose/history.plugin';
6+
7+
export enum AuditOperation {
8+
INSERT = 'insert',
9+
UPDATE = 'update',
10+
DELETE = 'delete',
11+
REPLACE = 'replace',
12+
}
13+
14+
export type AuditsDocument = Audits & Document;
15+
16+
@Schema({ versionKey: false, collection: 'audits' })
17+
export class Audits extends AbstractSchema {
18+
@Prop({
19+
type: String,
20+
required: true,
21+
})
22+
public coll!: string;
23+
24+
@Prop({
25+
type: Types.ObjectId,
26+
required: true,
27+
})
28+
public documentId!: Types.ObjectId;
29+
30+
@Prop({
31+
type: String,
32+
required: true,
33+
enum: AuditOperation,
34+
})
35+
public op!: 'insert' | 'update' | 'delete' | 'replace';
36+
37+
@Prop({
38+
type: AgentPartSchema,
39+
required: true,
40+
})
41+
public agent!: AgentPart;
42+
43+
@Prop({ type: Object })
44+
public data?: Document;
45+
46+
@Prop({ type: Array, of: Object })
47+
public changes?: ChangesType[];
48+
}
49+
50+
export const AuditsSchema = SchemaFactory.createForClass(Audits);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Controller } from '@nestjs/common';
2+
import { AbstractController } from '~/_common/abstracts/abstract.controller';
3+
import { ApiTags } from '@nestjs/swagger';
4+
import { PartialProjectionType } from '~/_common/types/partial-projection.type';
5+
import { AuditsService } from '~/core/audits/audits.service';
6+
7+
@ApiTags('core/audits')
8+
@Controller('audits')
9+
export class AuditsController extends AbstractController {
10+
protected static readonly projection: PartialProjectionType<any> = {};
11+
12+
public constructor(private readonly _service: AuditsService) {
13+
super();
14+
}
15+
}

src/core/audits/audits.module.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Module } from '@nestjs/common';
2+
import { MongooseModule } from '@nestjs/mongoose';
3+
import { AuditsSchema, Audits } from '~/core/audits/_schemas/audits.schema';
4+
import { AuditsService } from './audits.service';
5+
import { AuditsController } from './audits.controller';
6+
7+
@Module({
8+
imports: [
9+
MongooseModule.forFeatureAsync([
10+
{
11+
name: Audits.name,
12+
useFactory: () => AuditsSchema,
13+
},
14+
]),
15+
],
16+
providers: [AuditsService],
17+
controllers: [AuditsController],
18+
exports: [AuditsService],
19+
})
20+
export class AuditsModule { }

src/core/audits/audits.service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectModel } from '@nestjs/mongoose';
3+
import { Audits } from '~/core/audits/_schemas/audits.schema';
4+
import { Model } from 'mongoose';
5+
import { AbstractServiceSchema } from '~/_common/abstracts/abstract.service.schema';
6+
7+
@Injectable()
8+
export class AuditsService extends AbstractServiceSchema {
9+
constructor(@InjectModel(Audits.name) protected _model: Model<Audits>) {
10+
super();
11+
}
12+
}

src/core/auth/_strategies/jwt.strategy.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { AuthService } from '../auth.service';
66
import { Request } from 'express';
77
import { AgentType } from '~/_common/types/agent.type';
88
import { JwtPayload } from 'jsonwebtoken';
9+
import { Keyrings } from '~/core/keyrings/_schemas/keyrings.schema';
10+
import { Agents } from '~/core/agents/_schemas/agents.schema';
911

1012
@Injectable()
1113
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@@ -34,6 +36,11 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
3436
const user = await this.auth.verifyIdentity(payload);
3537

3638
if (!user) return done(new ForbiddenException(), false);
37-
return done(null, payload?.identity);
39+
return done(null, {
40+
$ref: !payload.scopes.includes('api')
41+
? Agents.name
42+
: Keyrings.name,
43+
...payload?.identity,
44+
});
3845
}
3946
}

src/core/core.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { KeyringsModule } from './keyrings/keyrings.module';
1010
import { LoggerModule } from './logger/logger.module';
1111
import { TasksModule } from './tasks/tasks.module';
1212
import { FilestorageModule } from './filestorage/filestorage.module';
13+
import { AuditsModule } from './audits/audits.module';
1314

1415
@Module({
1516
imports: [
@@ -21,6 +22,7 @@ import { FilestorageModule } from './filestorage/filestorage.module';
2122
JobsModule,
2223
TasksModule,
2324
FilestorageModule,
25+
AuditsModule,
2426
],
2527
providers: [CoreService],
2628
controllers: [CoreController],

src/management/identities/_schemas/identities.schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { MixedValue } from '~/_common/types/mixed-value.type';
1111
import { AutoIncrementPlugin } from '~/_common/plugins/mongoose/auto-increment.plugin';
1212
import { AutoIncrementPluginOptions } from '~/_common/plugins/mongoose/auto-increment.interface';
1313
import { DataStatusEnum } from '~/management/identities/_enums/data-status';
14+
import { historyPlugin } from '~/_common/plugins/mongoose/history.plugin';
1415

1516
export type IdentitiesDocument = Identities & Document;
1617

@@ -70,6 +71,13 @@ export class Identities extends AbstractSchema {
7071
}
7172

7273
export const IdentitiesSchema = SchemaFactory.createForClass(Identities)
74+
.plugin(historyPlugin, {
75+
collectionName: Identities.name,
76+
ignoredFields: [
77+
'lastSync',
78+
'fingerprint',
79+
],
80+
})
7381
.plugin(AutoIncrementPlugin, <AutoIncrementPluginOptions>{
7482
incrementBy: 1,
7583
field: 'inetOrgPerson.employeeNumber',

0 commit comments

Comments
 (0)