diff --git a/src/modules/api/admin/admin.module.ts b/src/modules/api/admin/admin.module.ts index e69b044..2791f24 100644 --- a/src/modules/api/admin/admin.module.ts +++ b/src/modules/api/admin/admin.module.ts @@ -11,12 +11,14 @@ import { PrivilegeAdminController } from './privilege-admin.controller'; import { UserAdminController } from './user-admin.controller'; import { ConfigurationModule } from 'src/modules/config/config.module'; import { AdminService } from './admin.service'; +import { AuditAdminController } from './audit-admin.controller'; @Module({ controllers: [ UserAdminController, PrivilegeAdminController, OAuth2AdminController, + AuditAdminController, ], imports: [ ObjectsModule, diff --git a/src/modules/api/admin/audit-admin.controller.ts b/src/modules/api/admin/audit-admin.controller.ts new file mode 100644 index 0000000..6ac389b --- /dev/null +++ b/src/modules/api/admin/audit-admin.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Privileges } from 'src/decorators/privileges.decorator'; +import { Scopes } from 'src/decorators/scopes.decorator'; +import { OAuth2Guard } from 'src/guards/oauth2.guard'; +import { PrivilegesGuard } from 'src/guards/privileges.guard'; +import { ScopesGuard } from 'src/guards/scopes.guard'; +import { AuditAction } from 'src/modules/objects/audit/audit.enum'; +import { AuditSearchClause } from 'src/modules/objects/audit/audit.interfaces'; +import { AuditService } from 'src/modules/objects/audit/audit.service'; +import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; +import { PaginationService } from 'src/modules/utility/services/paginate.service'; +import { PageOptions } from 'src/types/pagination.interfaces'; + +@ApiBearerAuth() +@ApiTags('admin') +@Controller('/api/admin/audit') +@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard) +export class AuditAdminController { + constructor( + private _paginate: PaginationService, + private _form: FormUtilityService, + private _audit: AuditService, + ) {} + + /** + * Get a list of all audit logs or search for a specific log + * @param search Search and pagination options + * @returns Paginated audit list + */ + @Get('') + @Scopes('management') + @Privileges('admin', 'admin:audit') + async userList(@Query() search: AuditSearchClause & PageOptions) { + const resultCount = await this._audit.searchForAuditCount(search); + + const pagination = this._paginate.paginate(search, resultCount); + + const [list] = await this._audit.searchForAudit( + pagination.pageSize, + pagination.offset, + search, + ); + + return { + pagination, + list: this._form.stripObjectArray(list, ['password']), + }; + } + + @Get('filter') + @Scopes('management') + @Privileges('admin', 'admin:audit') + async filterList() { + return Object.values(AuditAction); + } +} diff --git a/src/modules/objects/audit/audit.interfaces.ts b/src/modules/objects/audit/audit.interfaces.ts new file mode 100644 index 0000000..8270dad --- /dev/null +++ b/src/modules/objects/audit/audit.interfaces.ts @@ -0,0 +1,19 @@ +import { Lookup } from 'geoip-lite'; +import { Details } from 'express-useragent'; +import { AuditAction } from './audit.enum'; + +export interface UserLoginEntry { + login_at: Date; + current: boolean; + location: Partial; + user_agent: Partial
; +} + +export interface AuditSearchClause { + actions?: AuditAction[] | string; + user?: string; + ip?: string; + ua?: string; + content?: string; + flagged?: boolean; +} diff --git a/src/modules/objects/audit/audit.service.ts b/src/modules/objects/audit/audit.service.ts index 970f64b..9fa3bb8 100644 --- a/src/modules/objects/audit/audit.service.ts +++ b/src/modules/objects/audit/audit.service.ts @@ -1,19 +1,22 @@ import { Inject, Injectable } from '@nestjs/common'; import { Request } from 'express'; -import { Repository } from 'typeorm'; +import { + FindManyOptions, + FindOptionsWhere, + ILike, + In, + Repository, +} from 'typeorm'; import { User } from '../user/user.entity'; import { AuditLog } from './audit.entity'; import { AuditAction } from './audit.enum'; -import { lookup, Lookup } from 'geoip-lite'; -import { parse, Details } from 'express-useragent'; +import { Lookup, lookup } from 'geoip-lite'; +import { Details, parse } from 'express-useragent'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; +import { AuditSearchClause, UserLoginEntry } from './audit.interfaces'; -export interface UserLoginEntry { - login_at: Date; - current: boolean; - location: Partial; - user_agent: Partial
; -} +const PLUCK_LOCATION = ['country', 'city', 'timezone', 'll']; +const PLUCK_USER_AGENT = ['browser', 'version', 'os', 'platform']; @Injectable() export class AuditService { @@ -72,6 +75,52 @@ export class AuditService { return parse(ua); } + public async searchForAuditCount(search: AuditSearchClause) { + return this.audit.count(this.buildAuditSearch(search)); + } + + public async searchForAudit( + limit = 50, + offset = 0, + search: AuditSearchClause, + ): Promise< + [ + (AuditLog & { + location?: Partial; + user_agent?: Partial
; + })[], + number, + ] + > { + const [list, num] = await this.audit.findAndCount({ + ...this.buildAuditSearch(search), + take: limit, + skip: offset, + order: { created_at: 'DESC' }, + relations: ['actor'], + }); + + return [ + list.map((entry) => ({ + ...entry, + location: entry.actor_ip + ? this.form.pluckObject( + this.getIPLocation(entry.actor_ip), + PLUCK_LOCATION, + ) + : null, + user_agent: entry.actor_ua + ? this.form.pluckObject( + this.getUserAgentInfo(entry.actor_ua), + PLUCK_USER_AGENT, + ) + : null, + actor: this.form.stripObject(entry.actor, ['password']), + })), + num, + ]; + } + public async getUserLogins( user: User, sessid?: string, @@ -88,20 +137,16 @@ export class AuditService { login_at: entry.created_at, current: sessid === entry.content, location: entry.actor_ip - ? this.form.pluckObject(this.getIPLocation(entry.actor_ip), [ - 'country', - 'city', - 'timezone', - 'll', - ]) + ? this.form.pluckObject( + this.getIPLocation(entry.actor_ip), + PLUCK_LOCATION, + ) : null, user_agent: entry.actor_ua - ? this.form.pluckObject(this.getUserAgentInfo(entry.actor_ua), [ - 'browser', - 'version', - 'os', - 'platform', - ]) + ? this.form.pluckObject( + this.getUserAgentInfo(entry.actor_ua), + PLUCK_USER_AGENT, + ) : null, }); }); @@ -122,20 +167,16 @@ export class AuditService { created_at: auditEntry.created_at, ip: auditEntry.actor_ip, location: auditEntry.actor_ip - ? this.form.pluckObject(this.getIPLocation(auditEntry.actor_ip), [ - 'country', - 'city', - 'timezone', - 'll', - ]) + ? this.form.pluckObject( + this.getIPLocation(auditEntry.actor_ip), + PLUCK_LOCATION, + ) : null, user_agent: auditEntry.actor_ua - ? this.form.pluckObject(this.getUserAgentInfo(auditEntry.actor_ua), [ - 'browser', - 'version', - 'os', - 'platform', - ]) + ? this.form.pluckObject( + this.getUserAgentInfo(auditEntry.actor_ua), + PLUCK_USER_AGENT, + ) : null, }; } @@ -143,4 +184,35 @@ export class AuditService { public async updateAudit(audit: AuditLog): Promise { await this.audit.save(audit); } + + private buildAuditSearch( + search: AuditSearchClause, + ): FindManyOptions { + const obj: FindOptionsWhere = {}; + if (search.actions) { + obj.action = In((search.actions as string).split(',')); + } + + if (search.content) { + obj.content = ILike(`%${search.content}%`); + } + + if (search.ip) { + obj.actor_ip = ILike(`%${search.ip}%`); + } + + if (search.ua) { + obj.actor_ua = ILike(`%${search.ua}%`); + } + + if (search.user) { + obj.actor = { uuid: search.user }; + } + + if (search.flagged) { + obj.flagged = search.flagged; + } + + return { where: obj }; + } }