Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1c2eb10
feature branch start
bryan-robitaille Nov 24, 2025
bc71ecd
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Nov 27, 2025
448017b
* restore audit log work
ShadeWyrm Nov 27, 2025
4746cea
* Pull all audit extra details into lib.
ShadeWyrm Nov 27, 2025
57d1419
* fix a bunch of jest errors
ShadeWyrm Nov 27, 2025
7a96d84
* fix jest tests
ShadeWyrm Nov 27, 2025
79aa988
* remove unused declaration
ShadeWyrm Nov 27, 2025
0c15be5
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Nov 27, 2025
d301623
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Nov 28, 2025
2bdc3d6
* paramaterize the audit logs
ShadeWyrm Dec 1, 2025
f66bcc7
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Dec 1, 2025
9fedbd4
* fix params
ShadeWyrm Dec 1, 2025
11c8e9a
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Dec 2, 2025
9810e63
* Hook up translation and change event desc format to JSON.
ShadeWyrm Dec 2, 2025
4ddd3fa
* update jest tests to translation string key.
ShadeWyrm Dec 2, 2025
11dbc08
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Dec 2, 2025
650f213
* refactor to mapping instead of chain
ShadeWyrm Dec 2, 2025
4926654
* remove $, not used in t() template
ShadeWyrm Dec 3, 2025
7f8878f
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Dec 3, 2025
9eac52e
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Dec 3, 2025
c992c8b
Apply suggestions from code review
ShadeWyrm Dec 3, 2025
f3e6953
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Dec 3, 2025
de41d77
* remove unused legacy strings
ShadeWyrm Dec 3, 2025
13332bc
* add params i18n
ShadeWyrm Dec 4, 2025
f0f7483
Merge branch 'main' into feature/standard_action_logs
ShadeWyrm Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
"use server";
import { dynamoDBDocumentClient } from "@lib/integration/awsServicesConnector";
import {
BatchGetCommand,
QueryCommand,
QueryCommandInput,
QueryCommandOutput,
BatchGetCommandOutput,
} from "@aws-sdk/lib-dynamodb";
import { logEvent } from "@lib/auditLogs";
import { AuditLogDetails, logEvent, retrieveEvents } from "@lib/auditLogs";
import { prisma } from "@lib/integration/prismaConnector";
import { authorization } from "@lib/privileges";
import { AccessControlError } from "@lib/auth/errors";
Expand All @@ -23,104 +15,11 @@ import {
pipe,
} from "valibot";

const _retrieveEvents = async (query: QueryCommandInput) => {
const request = new QueryCommand(query);

const response = (await dynamoDBDocumentClient.send(request)) as QueryCommandOutput;
const { Items: eventsIndex, Count: eventsIndexCount } = response;

if (eventsIndexCount === 0 || eventsIndex === undefined) {
return [];
}
const eventItems = await _retrieveAuditLogs(eventsIndex);

return eventItems
.map((record) => {
return {
userId: record.UserID,
event: record.Event,
timestamp: record.TimeStamp,
description: record.Description,
subject: record.Subject,
};
})
.sort((a, b) => {
return b.timestamp - a.timestamp;
});
};

const _retrieveAuditLogs = async (keys: Array<Record<string, string>>) => {
let retries = 0;
const maxRetries = 3;
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const auditLogs: Array<{
UserID: string;
Event: string;
TimeStamp: number;
Description: string;
Subject: string;
}> = [];

const batchRequest = new BatchGetCommand({
RequestItems: {
AuditLogs: {
Keys: keys.map((event) => ({
UserID: event.UserID,
"Event#SubjectID#TimeStamp": event["Event#SubjectID#TimeStamp"],
})),
},
},
});

await dynamoDBDocumentClient.send(batchRequest).then(async (data: BatchGetCommandOutput) => {
auditLogs.push(
...(data?.Responses?.AuditLogs?.map((item: Record<string, string | number>) => ({
UserID: item.UserID as string,
Event: item.Event as string,
TimeStamp: item.TimeStamp as number,
Description: item.Description as string,
Subject: item.Subject as string,
})) ?? [])
);

if (data.UnprocessedKeys?.AuditLogs) {
while (retries < maxRetries) {
// eslint-disable-next-line no-await-in-loop -- Intentional retry logic with delay
await delay(200); // Wait for 200ms second before retrying
const retryRequest = new BatchGetCommand({
RequestItems: {
AuditLogs: {
Keys: data.UnprocessedKeys.AuditLogs.Keys,
},
},
});
const retryResponse: BatchGetCommandOutput =
// eslint-disable-next-line no-await-in-loop -- Intentional retry logic
await dynamoDBDocumentClient.send(retryRequest);
auditLogs.push(
...(retryResponse.Responses?.AuditLogs.map((item: Record<string, string | number>) => ({
UserID: item.UserID as string,
Event: item.Event as string,
TimeStamp: item.TimeStamp as number,
Description: item.Description as string,
Subject: item.Subject as string,
})) ?? [])
);
if (!retryResponse.UnprocessedKeys?.AuditLogs) {
break; // Exit the loop if there are no more unprocessed keys
}
retries++;
}
}
});
return auditLogs;
};

export const getEventsForUser = async (userId: string) => {
const {
user: { id: callingUserId },
} = await authorization.canViewAllUsers();
const events = await _retrieveEvents({
const events = await retrieveEvents({
TableName: "AuditLogs",
IndexName: "UserByTime",
Limit: 100,
Expand All @@ -134,7 +33,8 @@ export const getEventsForUser = async (userId: string) => {
callingUserId,
{ type: "User", id: userId },
"AuditLogsRead",
`User ${callingUserId} read audit logs for user ${userId}`
AuditLogDetails.UserAuditLogsRead,
{ callingUserId, userId }
);
return events;
};
Expand All @@ -144,7 +44,7 @@ export const getEventsForForm = async (formId: string) => {
user: { id: callingUserId },
} = await authorization.canViewAllForms();

const events = await _retrieveEvents({
const events = await retrieveEvents({
TableName: "AuditLogs",
IndexName: "SubjectByTimestamp",
Limit: 100,
Expand All @@ -158,7 +58,8 @@ export const getEventsForForm = async (formId: string) => {
callingUserId,
{ type: "Form", id: formId },
"AuditLogsRead",
`User ${callingUserId} read audit logs for form ${formId}`
AuditLogDetails.FormAuditLogsRead,
{ callingUserId, formId }
);
return events;
};
Expand All @@ -174,12 +75,9 @@ const formSchema = object({
export const findSubject = async (_: unknown, formData: FormData) => {
authorization.hasAdministrationPrivileges().catch((e) => {
if (e instanceof AccessControlError) {
logEvent(
e.user.id,
{ type: "User" },
"AccessDenied",
`Attempted to get events for Subject ${formData.get("subject")}`
);
logEvent(e.user.id, { type: "User" }, "AccessDenied", AuditLogDetails.GetAuditSubject, {
subject: formData.get("subject") as string,
});
}
throw e;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Language, FormServerErrorCodes, ServerActionError } from "@lib/types/form-builder-types";
import { getAppSetting } from "@lib/appSettings";
import { logEvent } from "@lib/auditLogs";
import { AuditLogDetails, logEvent } from "@lib/auditLogs";
import { ucfirst } from "@lib/client/clientHelpers";
import {
Answer,
Expand Down Expand Up @@ -519,7 +519,8 @@ const logDownload = async (
userId,
{ type: "Response", id: item.id },
"DownloadResponse",
`Downloaded form response in ${format} for submission ID ${item.id}`
AuditLogDetails.DownloadedFormResponses,
{ format: format, "item.id": item.id }
);
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@
import { createKey, deleteKey, refreshKey } from "@lib/serviceAccount";
import { revalidatePath } from "next/cache";
import { AuthenticatedAction } from "@lib/actions";
import { authorization } from "@lib/privileges";
import { AccessControlError } from "@lib/auth/errors";

import { submissionTypeExists } from "@lib/vault";
import { VaultStatus } from "@lib/types";
import { ServerActionError } from "@root/lib/types/form-builder-types";

import {
retrieveEvents,
logEvent,
AuditLogDetails,
AuditLogAccessDeniedDetails,
} from "@lib/auditLogs";

// Public facing functions - they can be used by anyone who finds the associated server action identifer

export const createServiceAccountKey = AuthenticatedAction(async (_, templateId: string) => {
Expand Down Expand Up @@ -47,3 +56,58 @@ export const unConfirmedResponsesExist = AuthenticatedAction(async (_, formId: s
return { error: "There was an error. Please try again later." } as ServerActionError;
}
});

export const getEventsForForm = AuthenticatedAction(async (session, formId: string) => {
try {
await authorization.canViewForm(formId).catch((e) => {
if (e instanceof AccessControlError) {
logEvent(
e.user.id,
{ type: "User" },
"AccessDenied",
AuditLogAccessDeniedDetails.AccessDenied_AttemptedToGetUserEmails
);
}
throw e;
});

const events = await retrieveEvents(
{
TableName: "AuditLogs",
IndexName: "SubjectByTimestamp",
Limit: 100,
KeyConditionExpression: "Subject = :formId",
ExpressionAttributeValues: {
":formId": `Form#${formId}`,
},
ScanIndexForward: false,
},
{
filter: ["ReadForm", "AuditLogsRead", "ListResponses"],
mapUserEmail: true,
}
);

const userId = session.user.id;

logEvent(
userId,
{ type: "Form", id: formId },
"AuditLogsRead",
AuditLogDetails.FormAuditLogsRead,
{ callingUserId: userId, formId: formId }
);

return events.map((event) => {
return {
formId: event.subject.split("#")[1],
userId: event.userId,
event: event.event,
timestamp: new Date(event.timestamp).toISOString(),
description: event.description,
};
});
} catch (error) {
return { error: "There was an error. Please try again later." } as ServerActionError;
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "@lib/cache/throttlingCache";
import { AuthenticatedAction } from "@lib/actions";
import { ServerActionError } from "@lib/types/form-builder-types";
import { logEvent } from "@lib/auditLogs";
import { AuditLogDetails, logEvent } from "@lib/auditLogs";

// Public facing functions - they can be used by anyone who finds the associated server action identifer

Expand All @@ -27,7 +27,8 @@ export const temporarilyIncreaseThrottlingRate = AuthenticatedAction(
session.user.id,
{ type: "ServiceAccount" },
"IncreaseThrottlingRate",
`User :${session.user.id} increased throttling rate on form ${formId} for ${weeks} week(s)`
AuditLogDetails.IncreasedThrottling,
{ formId: formId, weeks: weeks.toString(), userId: session.user.id }
);
} catch (error) {
return { error: "There was an error. Please try again later." } as ServerActionError;
Expand All @@ -44,7 +45,8 @@ export const permanentlyIncreaseThrottlingRate = AuthenticatedAction(
session.user.id,
{ type: "ServiceAccount" },
"IncreaseThrottlingRate",
`User :${session.user.id} permanently increased throttling rate on form ${formId}`
AuditLogDetails.PermanentIncreasedThrottling,
{ formId: formId, userId: session.user.id }
);
} catch (error) {
return { error: "There was an error. Please try again later." } as ServerActionError;
Expand All @@ -60,7 +62,8 @@ export const resetThrottlingRate = AuthenticatedAction(async (session, formId: s
session.user.id,
{ type: "ServiceAccount" },
"ResetThrottlingRate",
`User :${session.user.id} reset throttling rate on form ${formId}`
AuditLogDetails.ResetThrottling,
{ formId: formId, userId: session.user.id }
);
} catch (error) {
return { error: "There was an error. Please try again later." } as ServerActionError;
Expand Down
Loading
Loading