Skip to content

Commit 0e011cb

Browse files
feature: Audit Logs (#54)
1 parent 7c1d4fa commit 0e011cb

File tree

15 files changed

+488
-12
lines changed

15 files changed

+488
-12
lines changed

@types/express/index.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ declare global {
55
// biome-ignore lint/style/noNamespace: <It is the only way to do this. If we use module Biome also complains>
66
namespace Express {
77
interface Request {
8-
serviceAccountId?: string;
8+
serviceAccountId: string;
9+
serviceUserId: string;
910
}
1011
}
1112
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"dependencies": {
2222
"@aws-sdk/client-dynamodb": "^3.629.0",
2323
"@aws-sdk/client-secrets-manager": "^3.637.0",
24+
"@aws-sdk/client-sqs": "^3.645.0",
2425
"@aws-sdk/lib-dynamodb": "^3.629.0",
2526
"axios": "^1.7.3",
2627
"dotenv": "^16.4.5",

pnpm-lock.yaml

Lines changed: 336 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/auditLogs.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { GetQueueUrlCommand, SendMessageCommand } from "@aws-sdk/client-sqs";
2+
import { AwsServicesConnector } from "./connectors/awsServicesConnector.js";
3+
import { logMessage } from "./logger.js";
4+
import { EnvironmentMode, ENVIRONMENT_MODE } from "@src/config.js";
5+
6+
export enum AuditLogEvent {
7+
// Form Response Events
8+
DownloadResponse = "DownloadResponse",
9+
ConfirmResponse = "ConfirmResponse",
10+
IdentifyProblemResponse = "IdentifyProblemResponse",
11+
RetrieveNewResponses = "RetrieveResponses",
12+
// Application Events
13+
AccessDenied = "AccessDenied",
14+
// Template Events
15+
RetrieveTemplate = "RetrieveTemplate",
16+
}
17+
export type AuditLogEventStrings = keyof typeof AuditLogEvent;
18+
19+
export enum AuditSubjectType {
20+
ServiceAccount = "ServiceAccount",
21+
Form = "Form",
22+
Response = "Response",
23+
}
24+
25+
let queueUrlRef: string | null = null;
26+
27+
const getQueueUrl = async () => {
28+
if (!queueUrlRef) {
29+
const data = await AwsServicesConnector.getInstance().sqsClient.send(
30+
new GetQueueUrlCommand({
31+
QueueName: "api_audit_log_queue",
32+
}),
33+
);
34+
queueUrlRef = data.QueueUrl ?? null;
35+
logMessage.debug(`Audit Log Queue URL initialized: ${queueUrlRef}`);
36+
}
37+
return queueUrlRef;
38+
};
39+
40+
//Initialise the queueUrlRef on load except when running tests
41+
// This mock should be refactored when we once we have a better way to mock the AWS SDK
42+
process.env.NODE_ENV !== "test" && getQueueUrl();
43+
44+
export const logEvent = async (
45+
userId: string,
46+
subject: { type: keyof typeof AuditSubjectType; id?: string },
47+
event: AuditLogEventStrings,
48+
description?: string,
49+
): Promise<void> => {
50+
const auditLog = JSON.stringify({
51+
userId,
52+
event,
53+
timestamp: Date.now(),
54+
subject,
55+
description,
56+
});
57+
try {
58+
const queueUrl = await getQueueUrl();
59+
if (!queueUrl) {
60+
throw new Error("Audit Log Queue not connected");
61+
}
62+
await AwsServicesConnector.getInstance().sqsClient.send(
63+
new SendMessageCommand({
64+
MessageBody: auditLog,
65+
QueueUrl: queueUrl,
66+
}),
67+
);
68+
} catch (e) {
69+
// Only log the error in Production environment.
70+
// Development may be running without LocalStack setup
71+
if (ENVIRONMENT_MODE === EnvironmentMode.Local) {
72+
return logMessage.info(`AuditLog:${auditLog}`);
73+
}
74+
75+
logMessage.error("ERROR with Audit Logging");
76+
logMessage.error(e as Error);
77+
// Ensure the audit event is not lost by sending to console
78+
logMessage.warn(`AuditLog:${auditLog}`);
79+
}
80+
};

src/lib/connectors/awsServicesConnector.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
22
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
33
import { AWS_REGION, LOCALSTACK_ENDPOINT } from "@src/config.js";
44
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
5+
import { SQSClient } from "@aws-sdk/client-sqs";
56

67
const globalConfig = {
78
region: AWS_REGION,
@@ -20,6 +21,8 @@ export class AwsServicesConnector {
2021

2122
public secretsClient: SecretsManagerClient;
2223

24+
public sqsClient: SQSClient;
25+
2326
private constructor() {
2427
this.dynamodbClient = DynamoDBDocumentClient.from(
2528
new DynamoDBClient({
@@ -31,6 +34,11 @@ export class AwsServicesConnector {
3134
...globalConfig,
3235
...localstackConfig,
3336
});
37+
38+
this.sqsClient = new SQSClient({
39+
...globalConfig,
40+
...localstackConfig,
41+
});
3442
}
3543

3644
public static getInstance(): AwsServicesConnector {

src/lib/idp/introspectToken.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ZITADEL_APPLICATION_KEY, ZITADEL_DOMAIN } from "@src/config.js";
55
import { logMessage } from "@src/lib/logger.js";
66

77
export type IntrospectionResult = {
8-
username: string;
8+
serviceUserId: string;
99
exp: number;
1010
serviceAccountId: string;
1111
};
@@ -55,7 +55,7 @@ export async function introspectToken(
5555
}
5656

5757
return {
58-
username: introspectionResponse.username as string,
58+
serviceUserId: introspectionResponse.username as string,
5959
exp: introspectionResponse.exp as number,
6060
serviceAccountId: introspectionResponse.sub as string,
6161
};

src/middleware/authentication/middleware.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import {
44
getIntrospectionCache,
55
setIntrospectionCache,
66
} from "@lib/idp/introspectionCache.js";
7+
import { logEvent } from "@src/lib/auditLogs.js";
78

89
export async function authenticationMiddleware(
910
request: Request,
1011
response: Response,
1112
next: NextFunction,
1213
) {
1314
const accessToken = request.headers.authorization?.split(" ")[1];
15+
const formId = request.params.formId;
1416

1517
if (!accessToken) {
1618
return response.sendStatus(401);
@@ -24,18 +26,29 @@ export async function authenticationMiddleware(
2426
return response.sendStatus(403);
2527
}
2628

27-
const formId = request.params.formId;
28-
29-
if (introspectionResult.username !== formId) {
29+
if (introspectionResult.serviceUserId !== formId) {
30+
logEvent(
31+
introspectionResult.serviceUserId,
32+
{ type: "Form", id: formId },
33+
"AccessDenied",
34+
"User does not have access to this form",
35+
);
3036
return response.sendStatus(403);
3137
}
3238

3339
if (introspectionResult.exp < Date.now() / 1000) {
40+
logEvent(
41+
introspectionResult.serviceUserId,
42+
{ type: "Form", id: formId },
43+
"AccessDenied",
44+
"Access token has expired",
45+
);
3446
return response.status(401).json({ error: "Access token has expired" });
3547
}
3648

3749
await setIntrospectionCache(accessToken, introspectionResult);
3850

51+
request.serviceUserId = introspectionResult.serviceUserId;
3952
request.serviceAccountId = introspectionResult.serviceAccountId;
4053

4154
next();

src/routes/forms/formId/submission/new/router.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { logEvent } from "@src/lib/auditLogs.js";
12
import { logMessage } from "@src/lib/logger.js";
23
import { getNewFormSubmissions } from "@src/lib/vault/getNewFormSubmissions.js";
34
import { type Request, type Response, Router } from "express";
@@ -10,13 +11,20 @@ const MAXIMUM_NUMBER_OF_RETURNED_NEW_FORM_SUBMISSIONS: number = 100;
1011

1112
newApiRoute.get("/", async (request: Request, response: Response) => {
1213
const formId = request.params.formId;
14+
const serviceUserId = request.serviceUserId;
1315

1416
try {
1517
const newFormSubmissions = await getNewFormSubmissions(
1618
formId,
1719
MAXIMUM_NUMBER_OF_RETURNED_NEW_FORM_SUBMISSIONS,
1820
);
1921

22+
logEvent(
23+
serviceUserId,
24+
{ type: "Form", id: formId },
25+
"RetrieveNewResponses",
26+
);
27+
2028
return response.json(newFormSubmissions);
2129
} catch (error) {
2230
logMessage.error(

src/routes/forms/formId/submission/submissionName/confirm/router.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@src/lib/vault/dataStructures/exceptions.js";
77
import { type Request, type Response, Router } from "express";
88
import { logMessage } from "@src/lib/logger.js";
9+
import { logEvent } from "@src/lib/auditLogs.js";
910

1011
export const confirmApiRoute = Router({
1112
mergeParams: true,
@@ -17,9 +18,15 @@ confirmApiRoute.put(
1718
const formId = request.params.formId;
1819
const submissionName = request.params.submissionName;
1920
const confirmationCode = request.params.confirmationCode;
21+
const serviceUserId = request.serviceUserId;
2022

2123
try {
2224
await confirmFormSubmission(formId, submissionName, confirmationCode);
25+
logEvent(
26+
serviceUserId,
27+
{ type: "Response", id: submissionName },
28+
"ConfirmResponse",
29+
);
2330
return response.sendStatus(200);
2431
} catch (error) {
2532
if (error instanceof FormSubmissionNotFoundException) {

src/routes/forms/formId/submission/submissionName/problem/router.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { Schema } from "express-validator";
99
import { requestValidatorMiddleware } from "@src/middleware/requestValidator/middleware.js";
1010
import { ENVIRONMENT_MODE, EnvironmentMode } from "@src/config.js";
1111
import { logMessage } from "@src/lib/logger.js";
12+
import { logEvent } from "@src/lib/auditLogs.js";
1213

1314
export const problemApiRoute = Router({
1415
mergeParams: true,
@@ -41,6 +42,7 @@ problemApiRoute.post(
4142
async (request: Request, response: Response) => {
4243
const formId = request.params.formId;
4344
const submissionName = request.params.submissionName;
45+
const serviceUserId = request.serviceUserId;
4446

4547
const contactEmail = request.body.contactEmail as string;
4648
const description = request.body.description as string;
@@ -63,7 +65,11 @@ problemApiRoute.post(
6365
"[local] Will not notify support about submission problem.",
6466
);
6567
}
66-
68+
logEvent(
69+
serviceUserId,
70+
{ type: "Response", id: submissionName },
71+
"IdentifyProblemResponse",
72+
);
6773
return response.sendStatus(200);
6874
} catch (error) {
6975
if (error instanceof FormSubmissionNotFoundException) {

0 commit comments

Comments
 (0)