diff --git a/package-lock.json b/package-lock.json index ce68520..d3daaff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@aws-sdk/client-s3": "^3.657.0", "@aws-sdk/client-ses": "^3.515.0", "@inquirer/prompts": "^7.2.0", - "@pretendonetwork/grpc": "^2.6.1", + "@pretendonetwork/grpc": "^2.6.2", "bcrypt": "^5.0.0", "buffer-crc32": "^0.2.13", "colors": "^1.4.0", @@ -1680,9 +1680,9 @@ } }, "node_modules/@pretendonetwork/grpc": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.6.1.tgz", - "integrity": "sha512-+HKJ8cTV4AV8PJgUqg2Tocz4EJTajqErY9r/Oul8WIZM2lO+0gRPSf3Mlxi9Ghvjq88ZS9pWWccAKtn77ePrew==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.6.2.tgz", + "integrity": "sha512-fwHCejE25X4qmNLlSDLJwtAd52onLebaxhF9oazUIXbM+19OhEkMA9ISXu6hT5rLQ+RMbAXNNisNgr0+kOYyDQ==", "license": "AGPL-3.0-only", "dependencies": { "@bufbuild/protobuf": "^2.2.2", diff --git a/package.json b/package.json index 83c140c..f7e45ef 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@aws-sdk/client-s3": "^3.657.0", "@aws-sdk/client-ses": "^3.515.0", "@inquirer/prompts": "^7.2.0", - "@pretendonetwork/grpc": "^2.6.1", + "@pretendonetwork/grpc": "^2.6.2", "bcrypt": "^5.0.0", "buffer-crc32": "^0.2.13", "colors": "^1.4.0", diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts new file mode 100644 index 0000000..aaa9ccb --- /dev/null +++ b/src/models/audit-log.ts @@ -0,0 +1,58 @@ +import { Schema, model } from 'mongoose'; +import { AuditLogActionType } from '@pretendonetwork/grpc/account/v2/audit_log_action_type'; +import type { IAuditLog, IAuditLogComment, IAuditLogMethods, AuditLogModel } from '@/types/mongoose/audit-log'; + +const AuditLogCommentSchema = new Schema({ + pid: { + type: Number, + required: true + }, + created: { + type: Date, + default: Date.now + }, + content: { + type: String, + required: true + } +}); + +const AuditLogSchema = new Schema({ + action_type: { + type: Number, + enum: Object.values(AuditLogActionType).filter(value => typeof value === 'number'), + required: true, + index: true + }, + executed_by_pid: { + type: Number, + required: true, + index: true + }, + created: { + type: Date, + default: Date.now, + index: true + }, + metadata: { // * This is just arbitrary metadata about the log, which may be context-specific, for example holding the before and after of a user data change + type: String, + default: '{}', + validate: { + validator: (value: string): boolean => { + try { + JSON.parse(value); + return true; + } catch { + return false; + } + }, + message: 'metadata must be a valid JSON string' + } + }, + comments: { + type: [AuditLogCommentSchema], + default: [] + } +}); + +export const AuditLog = model('AuditLog', AuditLogSchema); diff --git a/src/services/grpc/account/v2/create-audit-log-comment.ts b/src/services/grpc/account/v2/create-audit-log-comment.ts index 4eb4d87..3d4f757 100644 --- a/src/services/grpc/account/v2/create-audit-log-comment.ts +++ b/src/services/grpc/account/v2/create-audit-log-comment.ts @@ -1,9 +1,49 @@ import { Status, ServerError } from 'nice-grpc'; +import { isValidObjectId, Types } from 'mongoose'; +import { AuditLog } from '@/models/audit-log'; +import { getExecutorPNID } from '@/services/grpc/account/v2/utils'; +import type { CallContext } from 'nice-grpc'; import type { CreateAuditLogCommentRequest, CreateAuditLogCommentResponse } from '@pretendonetwork/grpc/account/v2/create_audit_log_comment_rpc'; -export async function createAuditLogComment(_request: CreateAuditLogCommentRequest): Promise { - throw new ServerError( - Status.UNIMPLEMENTED, - 'Stubbed' - ); +export async function createAuditLogComment(request: CreateAuditLogCommentRequest, context: CallContext): Promise { + const pnid = await getExecutorPNID(context); + + if (!isValidObjectId(request.id)) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid audit log ID'); + } + + const content = request.content.trim(); + + if (content.length === 0) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Comment content cannot be empty'); + } + + const comment = { + _id: new Types.ObjectId(), + pid: pnid.pid, + created: new Date(), + content + }; + + const result = await AuditLog.updateOne({ + _id: request.id + }, { + $push: { + comments: comment + } + }); + + if (result.matchedCount === 0) { + throw new ServerError(Status.NOT_FOUND, 'Audit log not found'); + } + + return { + item: { + parent: request.id, + id: comment._id.toString(), + pid: comment.pid, + createdTimestamp: comment.created, + content: comment.content + } + }; } diff --git a/src/services/grpc/account/v2/list-audit-log-comments.ts b/src/services/grpc/account/v2/list-audit-log-comments.ts index 95616e3..33c3b8c 100644 --- a/src/services/grpc/account/v2/list-audit-log-comments.ts +++ b/src/services/grpc/account/v2/list-audit-log-comments.ts @@ -1,9 +1,28 @@ import { Status, ServerError } from 'nice-grpc'; +import { isValidObjectId } from 'mongoose'; +import { AuditLog } from '@/models/audit-log'; import type { ListAuditLogCommentsRequest, ListAuditLogCommentsResponse } from '@pretendonetwork/grpc/account/v2/list_audit_log_comments_rpc'; -export async function listAuditLogComments(_request: ListAuditLogCommentsRequest): Promise { - throw new ServerError( - Status.UNIMPLEMENTED, - 'Stubbed' - ); +export async function listAuditLogComments(request: ListAuditLogCommentsRequest): Promise { + if (!isValidObjectId(request.id)) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid audit log ID'); + } + + const auditLog = await AuditLog.findById(request.id, { + comments: 1 + }); + + if (!auditLog) { + throw new ServerError(Status.NOT_FOUND, 'Audit log not found'); + } + + return { + items: auditLog.comments.map(comment => ({ + parent: auditLog._id.toString(), + id: comment._id.toString(), + pid: comment.pid, + createdTimestamp: comment.created, + content: comment.content + })) + }; } diff --git a/src/services/grpc/account/v2/list-audit-logs.ts b/src/services/grpc/account/v2/list-audit-logs.ts index 813ec00..d2bfe4d 100644 --- a/src/services/grpc/account/v2/list-audit-logs.ts +++ b/src/services/grpc/account/v2/list-audit-logs.ts @@ -1,9 +1,54 @@ -import { Status, ServerError } from 'nice-grpc'; +import { AuditLog } from '@/models/audit-log'; +import type { FilterQuery } from 'mongoose'; import type { ListAuditLogsRequest, ListAuditLogsResponse } from '@pretendonetwork/grpc/account/v2/list_audit_logs_rpc'; +import type { IAuditLog } from '@/types/mongoose/audit-log'; -export async function listAuditLogs(_request: ListAuditLogsRequest): Promise { - throw new ServerError( - Status.UNIMPLEMENTED, - 'Stubbed' - ); +// * These seem sensible to me +const DEFAULT_PAGE_LIMIT = 50; +const MAX_PAGE_LIMIT = 500; + +export async function listAuditLogs(request: ListAuditLogsRequest): Promise { + const limit = Math.min(request.page?.limit || DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); + const offset = request.page?.offset ?? 0; + const filter: FilterQuery = {}; + + if (request.filter) { + if (request.filter.executedByPid.length !== 0) { + filter.executed_by_pid = { + $in: request.filter.executedByPid + }; + } + + if (request.filter.actionType.length !== 0) { + filter.action_type = { + $in: request.filter.actionType + }; + } + + // * extra_json is currently unused. See https://github.com/PretendoNetwork/grpc/pull/10#discussion_r2637508248 + } + + const [total, auditLogs] = await Promise.all([ + AuditLog.countDocuments(filter), + AuditLog.find(filter).sort({ + created: -1, + _id: -1 + }).skip(offset).limit(limit) + ]); + + return { + page: { + total, + offset, + itemCount: auditLogs.length + }, + items: auditLogs.map(auditLog => ({ + id: auditLog._id.toString(), + actionType: auditLog.action_type, + executedByPid: auditLog.executed_by_pid, + createdTimestamp: BigInt(auditLog.created.getTime()), + commentCount: auditLog.comments.length, + metadataJson: auditLog.metadata + })) + }; } diff --git a/src/services/grpc/account/v2/utils.ts b/src/services/grpc/account/v2/utils.ts new file mode 100644 index 0000000..9c0af0f --- /dev/null +++ b/src/services/grpc/account/v2/utils.ts @@ -0,0 +1,26 @@ +import { Status, ServerError } from 'nice-grpc'; +import { getPNIDByPID } from '@/database'; +import type { CallContext } from 'nice-grpc'; +import type { PNIDPermissionFlag } from '@/types/common/permission-flags'; +import type { HydratedPNIDDocument } from '@/types/mongoose/pnid'; + +// TODO - The API doesn't have a field for this itself, and this service wasn't made to have a token sent to it because of non-user system use. Should we port the token auth from the API service here anyway instead? +export async function getExecutorPNID(context: CallContext, permission?: PNIDPermissionFlag): Promise { + const executorPID = Number(context.metadata.get('X-Executor-PID')?.trim()); + + if (!Number.isInteger(executorPID) || executorPID <= 0) { + throw new ServerError(Status.INVALID_ARGUMENT, 'Missing or invalid X-Executor-PID metadata'); + } + + const pnid = await getPNIDByPID(executorPID); + + if (!pnid) { + throw new ServerError(Status.NOT_FOUND, 'Executor PNID not found'); + } + + if (permission !== undefined && !pnid.hasPermission(permission)) { + throw new ServerError(Status.PERMISSION_DENIED, 'Executor PNID lacks the required permission'); + } + + return pnid; +} diff --git a/src/types/mongoose/audit-log.ts b/src/types/mongoose/audit-log.ts new file mode 100644 index 0000000..f9ab246 --- /dev/null +++ b/src/types/mongoose/audit-log.ts @@ -0,0 +1,25 @@ +import type { Model, HydratedDocument, Types } from 'mongoose'; +import type { AuditLogActionType } from '@pretendonetwork/grpc/account/v2/audit_log_action_type'; + +export interface IAuditLogComment { + _id: Types.ObjectId; + pid: number; + created: Date; + content: string; +} + +export interface IAuditLog { + action_type: AuditLogActionType; + executed_by_pid: number; + created: Date; + metadata: string; + comments: Types.DocumentArray; +} + +export interface IAuditLogMethods {} + +interface IAuditLogQueryHelpers {} + +export interface AuditLogModel extends Model {} + +export type HydratedAuditLogDocument = HydratedDocument;