Skip to content

Commit

Permalink
feat(mixin): making the actor field configurable (#73)
Browse files Browse the repository at this point in the history
* feat(mixin): feat(mixin): making the actor field configurable

added a key to that can be bounded to key of user that can be set as actor
GH-68

GH-68

* feat(mixin): docs(mixin): add docs about how to configure actor id key

steps explaining how to pass the actor id via the repository constructor to change its value
GH-68

GH-68

* feat(mixin): added delete all hard and delete by id hard

in case current user is not available then can be passed through options

GH-68

* feat(mixin): making the actor field configurable

adding a separate function for actor to avoid repetition

GH-68

* feat(mixin): making actor id configurable

get actor from a private method

GH-68
  • Loading branch information
yeshamavani authored Aug 7, 2023
1 parent 3071b66 commit c6a2e4b
Show file tree
Hide file tree
Showing 9 changed files with 216 additions and 29 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,44 @@ This will create all insert, update, delete audits for this model.
create(data, {noAudit: true});
```

- The Actor field is now configurable and can save any string type value in the field.
Though the default value will be userId a developer can save any string field from the current User that is being passed.

```ts
export interface User<ID = string, TID = string, UTID = string> {
id?: string;
username: string;
password?: string;
identifier?: ID;
permissions: string[];
authClientId: number;
email?: string;
role: string;
firstName: string;
lastName: string;
middleName?: string;
tenantId?: TID;
userTenantId?: UTID;
passwordExpiryTime?: Date;
allowedResources?: string[];
}
```

### Steps

1. All you need to do is bind a User key to the ActorIdKey in application.ts

```ts
this.bind(AuthServiceBindings.ActorIdKey).to('username');
```

2. Pass the actorIdKey argument in the constructor

```ts
@inject(AuditBindings.ActorIdKey, {optional: true})
public actorIdKey?: ActorId,
```

- The package exposes a conditional mixin for your repository classes. Just extend your repository class with `ConditionalAuditRepositoryMixin`, for all those repositories where you need audit data based on condition whether `ADD_AUDIT_LOG_MIXIN` is set true. See an example below. For a model `Group`, here we are extending the `GroupRepository` with `AuditRepositoryMixin`.

```ts
Expand Down Expand Up @@ -262,6 +300,15 @@ export class GroupRepository extends ConditionalAuditRepositoryMixin(
}
```

### Making current user not mandatory

Incase you dont have current user binded in your application context and wish to log the activities within your application then in that case you can pass the actor id along with the
options just like

```ts
await productRepo.create(product, {noAudit: false, actorId: 'userId'});
```

## Using with Sequelize ORM

This extension provides support to both juggler (the default loopback ORM) and sequelize.
Expand Down
17 changes: 13 additions & 4 deletions src/__tests__/acceptance/audit.mixin.acceptance.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {expect, sinon} from '@loopback/testlab';
import {v4 as uuidv4} from 'uuid';
import {Action} from '../..';
import {Action, User} from '../..';
import {TestAuditDataSource} from './fixtures/datasources/audit.datasource';
import {TestDataSource} from './fixtures/datasources/test.datasource';
import {TestModel} from './fixtures/models/test.model';
Expand All @@ -16,9 +16,16 @@ console.error = (message: string) => {
consoleMessage = message;
};

const mockUser = {
id: 'testId',
name: 'testName',
const mockUser: User = {
id: 'testCurrentUserId',
username: 'testCurrentUserName',
authClientId: 123,
permissions: ['1', '2', '3'],
role: 'admin',
firstName: 'test',
lastName: 'lastname',
tenantId: 'tenantId',
userTenantId: 'userTenantId',
};

describe('Audit Mixin', () => {
Expand All @@ -27,13 +34,15 @@ describe('Audit Mixin', () => {
const auditLogRepositoryInstance = new TestAuditLogRepository(
new TestAuditDataSource(),
);
const actorIdKey = 'id';
const getAuditLogRepository = sinon
.stub()
.resolves(auditLogRepositoryInstance);
const testRepositoryInstance = new TestRepository(
testDataSourceInstance,
getCurrentUser,
getAuditLogRepository,
actorIdKey,
);

beforeEach(async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import {Constructor, Getter} from '@loopback/core';
import {DefaultCrudRepository} from '@loopback/repository';

import {AuditRepositoryMixin, IAuditMixinOptions} from '../../../..';
import {
ActorId,
AuditRepositoryMixin,
IAuditMixinOptions,
User,
} from '../../../..';
import {TestDataSource} from '../datasources/test.datasource';
import {TestModel, TestModelRelations} from '../models/test.model';
import {TestAuditLogRepository} from './audit.repository';
Expand All @@ -25,8 +30,9 @@ export class TestRepository extends AuditRepositoryMixin<
>(DefaultCrudRepository, testAuditOpts) {
constructor(
dataSource: TestDataSource,
public getCurrentUser: Getter<{name: string; id: string}>,
public getCurrentUser: Getter<User>,
public getAuditLogRepository: Getter<TestAuditLogRepository>,
public actorIdKey?: ActorId,
) {
super(TestModel, dataSource);
}
Expand Down
12 changes: 10 additions & 2 deletions src/__tests__/unit/audit.mixin.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AuditLog,
AuditRepositoryMixin,
IAuditMixinOptions,
User,
} from '../..';
import {consoleMessage} from '../acceptance/audit.mixin.acceptance';
import {
Expand Down Expand Up @@ -49,9 +50,16 @@ class MockAuditRepoError {
const mockOpts: IAuditMixinOptions = {
actionKey: 'Test_Logs',
};
const mockUser = {
const mockUser: User = {
id: 'testCurrentUserId',
name: 'testCurrentUserName',
username: 'testCurrentUserName',
authClientId: 123,
permissions: ['1', '2', '3'],
role: 'admin',
firstName: 'test',
lastName: 'lastname',
tenantId: 'tenantId',
userTenantId: 'userTenantId',
};

describe('Audit Mixin', () => {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './mixins';
export * from './types';
export * from './models';
export * from './repositories';
export * from './keys';
6 changes: 6 additions & 0 deletions src/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {BindingKey} from '@loopback/core';
import {ActorId} from './types';

export namespace AuditBindings {
export const ActorIdKey = BindingKey.create<ActorId>(`sf.audit.actorid`);
}
115 changes: 100 additions & 15 deletions src/mixins/audit.mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {AuditLogRepository} from '../repositories';
import {AuditLogRepository as SequelizeAuditLogRepository} from '../repositories/sequelize';
import {
AbstractConstructor,
ActorId,
AuditMixinBase,
AuditOptions,
IAuditMixin,
IAuditMixinOptions,
User,
} from '../types';

//sonarignore:start
Expand All @@ -31,7 +33,8 @@ export function AuditRepositoryMixin<
getAuditLogRepository: () => Promise<
AuditLogRepository | SequelizeAuditLogRepository
>;
getCurrentUser?: () => Promise<{id?: UserID}>;
getCurrentUser?: () => Promise<User>;
actorIdKey?: ActorId;

async create(
dataObject: DataObject<M>,
Expand All @@ -46,8 +49,7 @@ export function AuditRepositoryMixin<
delete extras.actionKey;
const audit = new AuditLog({
actedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: (user?.id as any)?.toString() ?? '0', //NOSONAR
actor: this.getActor(user, options?.actorId),
action: Action.INSERT_ONE,
after: created.toJSON(),
entityId: created.getId(),
Expand Down Expand Up @@ -82,8 +84,7 @@ export function AuditRepositoryMixin<
data =>
new AuditLog({
actedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: (user?.id as any).toString() ?? '0', //NOSONAR
actor: this.getActor(user, options?.actorId),
action: Action.INSERT_MANY,
after: data.toJSON(),
entityId: data.getId(),
Expand Down Expand Up @@ -127,8 +128,7 @@ export function AuditRepositoryMixin<
data =>
new AuditLog({
actedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: (user?.id as any).toString() ?? '0', //NOSONAR
actor: this.getActor(user, options?.actorId),
action: Action.UPDATE_MANY,
before: (beforeMap[data.getId()] as Entity).toJSON(),
after: data.toJSON(),
Expand Down Expand Up @@ -169,8 +169,7 @@ export function AuditRepositoryMixin<
data =>
new AuditLog({
actedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: (user?.id as any).toString() ?? '0', //NOSONAR
actor: this.getActor(user, options?.actorId),
action: Action.DELETE_MANY,
before: (beforeMap[data.getId()] as Entity).toJSON(),
entityId: data.getId(),
Expand Down Expand Up @@ -219,8 +218,7 @@ export function AuditRepositoryMixin<
delete extras.actionKey;
const auditLog = new AuditLog({
actedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: (user?.id as any).toString() ?? '0', //NOSONAR
actor: this.getActor(user, options?.actorId),
action: Action.UPDATE_ONE,
before: before.toJSON(),
after: after.toJSON(),
Expand Down Expand Up @@ -260,8 +258,7 @@ export function AuditRepositoryMixin<
delete extras.actionKey;
const auditLog = new AuditLog({
actedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: (user?.id as any).toString() ?? '0', //NOSONAR
actor: this.getActor(user, options?.actorId),
action: Action.UPDATE_ONE,
before: before.toJSON(),
after: after.toJSON(),
Expand Down Expand Up @@ -296,8 +293,7 @@ export function AuditRepositoryMixin<
delete extras.actionKey;
const auditLog = new AuditLog({
actedAt: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: (user?.id as any).toString() ?? '0', //NOSONAR
actor: this.getActor(user, options?.actorId),
action: Action.DELETE_ONE,
before: before.toJSON(),
entityId: before.getId(),
Expand All @@ -315,6 +311,95 @@ export function AuditRepositoryMixin<
});
}
}
async deleteAllHard(
where?: Where<M>,
options?: AuditOptions,
): Promise<Count> {
if (!super.deleteAllHard) {
throw new Error('Method not Found');
}
if (options?.noAudit) {
return super.deleteAllHard(where, options);
}
const toDelete = await this.find({where}, options);
const beforeMap = keyBy(toDelete, d => d.getId());
const deletedCount = await super.deleteAllHard(where, options);

if (this.getCurrentUser) {
const user = await this.getCurrentUser();
const auditRepo = await this.getAuditLogRepository();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const extras: any = Object.assign({}, opts); //NOSONAR
delete extras.actionKey;
const audits = toDelete.map(
data =>
new AuditLog({
actedAt: new Date(),
actor: this.getActor(user, options?.actorId),
action: Action.DELETE_MANY,
before: (beforeMap[data.getId()] as Entity).toJSON(),
entityId: data.getId(),
actedOn: this.entityClass.modelName,
actionKey: opts.actionKey,
...extras,
}),
);
auditRepo.createAll(audits).catch(() => {
const auditsJson = audits.map(a => a.toJSON());
//sonarignore:start
console.error(
`Audit failed for data => ${JSON.stringify(auditsJson)}`,
);
//sonarignore:end
});
}
return deletedCount;
}

async deleteByIdHard(id: ID, options?: AuditOptions): Promise<void> {
if (!super.deleteByIdHard) {
throw new Error('Method not Found');
}
if (options?.noAudit) {
return super.deleteByIdHard(id, options);
}
const before = await this.findById(id, undefined, options);
await super.deleteByIdHard(id, options);

if (this.getCurrentUser) {
const user = await this.getCurrentUser();
const auditRepo = await this.getAuditLogRepository();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const extras: any = Object.assign({}, opts); //NOSONAR
delete extras.actionKey;
const auditLog = new AuditLog({
actedAt: new Date(),
actor: this.getActor(user, options?.actorId),
action: Action.DELETE_ONE,
before: before.toJSON(),
entityId: before.getId(),
actedOn: this.entityClass.modelName,
actionKey: opts.actionKey,
...extras,
});

auditRepo.create(auditLog).catch(() => {
//sonarignore:start
console.error(
`Audit failed for data => ${JSON.stringify(auditLog.toJSON())}`,
);
//sonarignore:end
});
}
}
getActor(user: User, optionsActorId?: string): string {
return (
optionsActorId ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(user[this.actorIdKey ?? 'id'] as any)?.toString() ?? //NOSOAR
'0'
);
}
}
return MixedRepository;
}
10 changes: 5 additions & 5 deletions src/mixins/conditional-audit.mixin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {DefaultCrudRepository, Entity} from '@loopback/repository';
import {AbstractConstructor, IAuditMixinOptions} from '../types';
import {Entity} from '@loopback/repository';
import {AuditMixinBase, IAuditMixinOptions} from '../types';
import {AuditRepositoryMixin} from './audit.mixin';

/**
Expand All @@ -10,11 +10,11 @@ export function ConditionalAuditRepositoryMixin<
T extends Entity,
ID,
Relations extends object,
S extends AbstractConstructor<DefaultCrudRepository<T, ID, Relations>>,
S extends AuditMixinBase<T, ID, Relations>,
>(
constructor: S & AbstractConstructor<DefaultCrudRepository<T, ID, Relations>>,
constructor: S & AuditMixinBase<T, ID, Relations>,
opt: IAuditMixinOptions,
): S & AbstractConstructor<DefaultCrudRepository<T, ID, Relations>> {
): S & AuditMixinBase<T, ID, Relations> {
const ConditionalRepo =
process.env.ADD_AUDIT_LOG_MIXIN === 'true'
? AuditRepositoryMixin<T, ID, Relations, string, S>(constructor, opt)
Expand Down
Loading

0 comments on commit c6a2e4b

Please sign in to comment.