Skip to content

Commit b6bb60d

Browse files
authored
Audit Log Viewer (#119)
* add a basic UI for viewing logs * updates and add testas
1 parent d886170 commit b6bb60d

File tree

10 files changed

+822
-2
lines changed

10 files changed

+822
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,4 @@ __pycache__
142142
/blob-report/
143143
/playwright/.cache/
144144
dist_devel/
145+
!src/ui/pages/logs

src/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import stripeRoutes from "./routes/stripe.js";
2828
import membershipPlugin from "./routes/membership.js";
2929
import path from "path"; // eslint-disable-line import/no-nodejs-modules
3030
import roomRequestRoutes from "./routes/roomRequests.js";
31+
import logsPlugin from "./routes/logs.js";
3132

3233
dotenv.config();
3334

@@ -135,6 +136,7 @@ async function init(prettyPrint: boolean = false) {
135136
api.register(mobileWalletRoute, { prefix: "/mobileWallet" });
136137
api.register(stripeRoutes, { prefix: "/stripe" });
137138
api.register(roomRequestRoutes, { prefix: "/roomRequests" });
139+
api.register(logsPlugin, { prefix: "/logs" });
138140
if (app.runEnvironment === "dev") {
139141
api.register(vendingPlugin, { prefix: "/vending" });
140142
}

src/api/routes/logs.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { QueryCommand } from "@aws-sdk/client-dynamodb";
2+
import { unmarshall } from "@aws-sdk/util-dynamodb";
3+
import { createAuditLogEntry } from "api/functions/auditLog.js";
4+
import rateLimiter from "api/plugins/rateLimiter.js";
5+
import { genericConfig } from "common/config.js";
6+
import {
7+
BaseError,
8+
DatabaseFetchError,
9+
ValidationError,
10+
} from "common/errors/index.js";
11+
import { Modules } from "common/modules.js";
12+
import { AppRoles } from "common/roles.js";
13+
import fastify, { FastifyPluginAsync } from "fastify";
14+
import { request } from "http";
15+
16+
type GetLogsRequest = {
17+
Params: { module: string };
18+
Querystring: { start: number; end: number };
19+
Body: undefined;
20+
};
21+
22+
const logsPlugin: FastifyPluginAsync = async (fastify, _options) => {
23+
fastify.register(rateLimiter, {
24+
limit: 10,
25+
duration: 30,
26+
rateLimitIdentifier: "logs",
27+
});
28+
fastify.get<GetLogsRequest>(
29+
"/:module",
30+
{
31+
schema: {
32+
querystring: {
33+
type: "object",
34+
required: ["start", "end"],
35+
properties: {
36+
start: { type: "number" },
37+
end: { type: "number" },
38+
},
39+
additionalProperties: false,
40+
},
41+
},
42+
onRequest: async (request, reply) => {
43+
await fastify.authorize(request, reply, [AppRoles.AUDIT_LOG_VIEWER]);
44+
},
45+
preValidation: async (request, reply) => {
46+
const { module } = request.params;
47+
const { start, end } = request.query;
48+
49+
if (!Object.values(Modules).includes(module as Modules)) {
50+
throw new ValidationError({ message: `Invalid module "${module}".` });
51+
}
52+
if (end <= start) {
53+
throw new ValidationError({
54+
message: `End must be greater than start.`,
55+
});
56+
}
57+
},
58+
},
59+
async (request, reply) => {
60+
const { module } = request.params;
61+
const { start, end } = request.query;
62+
const logPromise = createAuditLogEntry({
63+
dynamoClient: fastify.dynamoClient,
64+
entry: {
65+
module: Modules.AUDIT_LOG,
66+
actor: request.username!,
67+
target: module,
68+
message: `Viewed audit log from ${start} to ${end}.`,
69+
},
70+
});
71+
const queryCommand = new QueryCommand({
72+
TableName: genericConfig.AuditLogTable,
73+
KeyConditionExpression: "#pk = :module AND #sk BETWEEN :start AND :end",
74+
ExpressionAttributeNames: {
75+
"#pk": "module",
76+
"#sk": "createdAt",
77+
},
78+
ExpressionAttributeValues: {
79+
":module": { S: module },
80+
":start": { N: start.toString() },
81+
":end": { N: end.toString() },
82+
},
83+
ScanIndexForward: false,
84+
});
85+
let response;
86+
try {
87+
response = await fastify.dynamoClient.send(queryCommand);
88+
if (!response.Items) {
89+
throw new DatabaseFetchError({
90+
message: "Error occurred fetching audit log.",
91+
});
92+
}
93+
} catch (e) {
94+
if (e instanceof BaseError) {
95+
throw e;
96+
}
97+
fastify.log.error(e);
98+
throw new DatabaseFetchError({
99+
message: "Error occurred fetching audit log.",
100+
});
101+
}
102+
await logPromise;
103+
reply.send(response.Items.map((x) => unmarshall(x)));
104+
},
105+
);
106+
};
107+
108+
export default logsPlugin;

src/common/modules.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,19 @@ export enum Modules {
66
EMAIL_NOTIFICATION = "emailNotification",
77
PROVISION_NEW_MEMBER = "provisionNewMember",
88
MOBILE_WALLET = "mobileWallet",
9-
LINKRY = "linkry"
9+
LINKRY = "linkry",
10+
AUDIT_LOG = "auditLog"
11+
}
12+
13+
14+
export const ModulesToHumanName: Record<Modules, string> = {
15+
[Modules.IAM]: "IAM",
16+
[Modules.EVENTS]: "Events",
17+
[Modules.STRIPE]: "Stripe Integration",
18+
[Modules.TICKETS]: "Ticketing/Merch",
19+
[Modules.EMAIL_NOTIFICATION]: "Email Notifications",
20+
[Modules.PROVISION_NEW_MEMBER]: "Member Provisioning",
21+
[Modules.MOBILE_WALLET]: "Mobile Wallet",
22+
[Modules.LINKRY]: "Link Shortener",
23+
[Modules.AUDIT_LOG]: "Audit Log",
1024
}

src/common/roles.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ export enum AppRoles {
1212
STRIPE_LINK_CREATOR = "create:stripeLink",
1313
BYPASS_OBJECT_LEVEL_AUTH = "bypass:ola",
1414
ROOM_REQUEST_CREATE = "create:roomRequest",
15-
ROOM_REQUEST_UPDATE = "update:roomRequest"
15+
ROOM_REQUEST_UPDATE = "update:roomRequest",
16+
AUDIT_LOG_VIEWER = "view:auditLog"
1617
}
1718
export const allAppRoles = Object.values(AppRoles).filter(
1819
(value) => typeof value === "string",

src/ui/Router.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ManageProfilePage } from './pages/profile/ManageProfile.page';
2222
import { ManageStripeLinksPage } from './pages/stripe/ViewLinks.page';
2323
import { ManageRoomRequestsPage } from './pages/roomRequest/RoomRequestLanding.page';
2424
import { ViewRoomRequest } from './pages/roomRequest/ViewRoomRequest.page';
25+
import { ViewLogsPage } from './pages/logs/ViewLogs.page';
2526

2627
const ProfileRediect: React.FC = () => {
2728
const location = useLocation();
@@ -185,6 +186,10 @@ const authenticatedRouter = createBrowserRouter([
185186
path: '/roomRequests/:semesterId/:requestId',
186187
element: <ViewRoomRequest />,
187188
},
189+
{
190+
path: '/logs',
191+
element: <ViewLogsPage />,
192+
},
188193
// Catch-all route for authenticated users shows 404 page
189194
{
190195
path: '*',

src/ui/components/AppShell/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
IconTicket,
1919
IconLock,
2020
IconDoor,
21+
IconHistory,
2122
} from '@tabler/icons-react';
2223
import { ReactNode } from 'react';
2324
import { useNavigate } from 'react-router-dom';
@@ -80,6 +81,13 @@ export const navItems = [
8081
description: null,
8182
validRoles: [AppRoles.LINKS_MANAGER, AppRoles.LINKS_ADMIN],
8283
},
84+
{
85+
link: '/logs',
86+
name: 'Audit Logs',
87+
icon: IconHistory,
88+
description: null,
89+
validRoles: [AppRoles.AUDIT_LOG_VIEWER],
90+
},
8391
];
8492

8593
export const extLinks = [

0 commit comments

Comments
 (0)