From 25d38f2cc42fc74f470742c7b3719e0937be6fda Mon Sep 17 00:00:00 2001 From: aln730 Date: Sun, 28 Jun 2026 13:20:59 -0400 Subject: [PATCH 01/15] feat: add query by date range --- routes/logs.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/routes/logs.js b/routes/logs.js index 326e312..f25429d 100644 --- a/routes/logs.js +++ b/routes/logs.js @@ -2,17 +2,23 @@ import { Router } from "express"; const router = Router(); router.get("/", async (req, res) => { - const cursor = req.query.cursor; - const query = cursor ? { timestamp: { $lt: new Date(cursor) } } : {}; + const { cursor, since, until } = req.query; + const query = {}; + if (since || until || cursor) { + query.timestamp = {}; + if (cursor) query.timestamp.$lt = new Date(cursor); + if (since) query.timestamp.$gte = new Date(since); + if (until) query.timestamp.$lte = new Date(until); + } const logs = await req.ctx.db .collection("accessLogs") .find(query) .sort({ timestamp: -1 }) - .limit(100) + .limit(50) .toArray(); - const nextCursor = logs.length > 0 + const nextCursor = logs.length === 50 ? logs[logs.length - 1].timestamp.toISOString() : null; From 1cb97220ad4c6a2ba88b7b1729505a8f0022046b Mon Sep 17 00:00:00 2001 From: aln730 Date: Wed, 8 Jul 2026 20:22:00 -0400 Subject: [PATCH 02/15] feat: keys by user, and more --- routes/keys.js | 216 ++++++++++++++++++++------------------- routes/memberProjects.js | 1 + server.js | 2 +- 3 files changed, 114 insertions(+), 105 deletions(-) diff --git a/routes/keys.js b/routes/keys.js index 886d287..8b1e57d 100644 --- a/routes/keys.js +++ b/routes/keys.js @@ -1,123 +1,131 @@ -import { Router } from "express"; -import crypto from "crypto"; -import { REALM_NAMES } from "../constants.js"; + import { Router } from "express"; + import crypto from "crypto"; + import { REALM_NAMES } from "../constants.js"; -const router = Router(); + const router = Router(); -// First, PUT /keys with details of user key is for -// Receive a keyId back which is our association -// Register key using association and send back the now-randomised UID -// with PATCH /keys/:id + router.get("/by-user", async (req, res) => { + if (typeof req.query.userId != "string") { + return res.status(422).json({ message: "Missing 'userId' query param" }); + } + const keys = await req.ctx.db.collection("keys").find({ userId: req.query.userId }).toArray(); + res.json(keys); + }); -router.put("/", async (req, res) => { - console.log(req.body); - if (typeof req.body.userId != "string") { - res.status(422).json({ - message: "No 'userId' field specified", - }); - return; - } - if (typeof req.body.uid != "string") { - res.status(422).json({ - message: "No 'uid' field specified", - }); - return; - } + // First, PUT /keys with details of user key is for + // Receive a keyId back which is our association + // Register key using association and send back the now-randomised UID + // with PATCH /keys/:id - const keys = {}; - for (const name of REALM_NAMES) { - keys[name + "Id"] = crypto.randomBytes(18).toString("hex"); - } + router.put("/", async (req, res) => { + console.log(req.body); + if (typeof req.body.userId != "string") { + res.status(422).json({ + message: "No 'userId' field specified", + }); + return; + } + if (typeof req.body.uid != "string") { + res.status(422).json({ + message: "No 'uid' field specified", + }); + return; + } - const insertedKey = await req.ctx.db.collection("keys").insertOne({ - // Make sure it's something at least reasonable... - _id: crypto.randomBytes(18).toString("hex"), - userId: req.body.userId, - uid: req.body.uid, - // Not created yet, so we'll just leave it disabled for now - enabled: false, + const keys = {}; + for (const name of REALM_NAMES) { + keys[name + "Id"] = crypto.randomBytes(18).toString("hex"); + } - ...keys, - }); - res.json({ - keyId: insertedKey.insertedId, - uid: req.body.uid, - ...keys, + const insertedKey = await req.ctx.db.collection("keys").insertOne({ + // Make sure it's something at least reasonable... + _id: crypto.randomBytes(18).toString("hex"), + userId: req.body.userId, + uid: req.body.uid, + // Not created yet, so we'll just leave it disabled for now + enabled: false, + + ...keys, + }); + res.json({ + keyId: insertedKey.insertedId, + uid: req.body.uid, + ...keys, + }); }); -}); -router.patch("/:id", async (req, res) => { - const updates = {}; - for (const key of ["uid", "userId", "enabled"]) { - if (key in req.body) { - updates[key] = req.body[key]; - } - } - await req.ctx.db.collection("keys").updateOne( - { - _id: {$eq: req.params.id}, - }, - { - $set: updates, + router.patch("/:id", async (req, res) => { + const updates = {}; + for (const key of ["uid", "userId", "enabled"]) { + if (key in req.body) { + updates[key] = req.body[key]; + } } - ); - res.status(204).send(null); -}); - -router.get("/by-association/:id", async (req, res) => { - const key = await req.ctx.db.collection("keys").findOne({ - $or: REALM_NAMES.map((key) => ({ - [key + "Id"]: { - $eq: req.params.id, + await req.ctx.db.collection("keys").updateOne( + { + _id: {$eq: req.params.id}, }, - })), + { + $set: updates, + } + ); + res.status(204).send(null); }); - if (key) { - const data = { - keyId: key._id, - uid: key.uid, - }; - for (const name of REALM_NAMES) { - data[name + "Id"] = key[name + "Id"]; - } - return res.json(data); - } else { - return res.status(404).json({ - message: "No such key!", + router.get("/by-association/:id", async (req, res) => { + const key = await req.ctx.db.collection("keys").findOne({ + $or: REALM_NAMES.map((key) => ({ + [key + "Id"]: { + $eq: req.params.id, + }, + })), }); - } -}); -router.delete("/by-user", async (req, res) => { - if (typeof req.body.userId != "string") { - return res.status(422).json({ - message: "Missing 'userId'", - }); - } - const results = await req.ctx.db.collection("keys").deleteMany({ - userId: {$eq: req.body.userId}, + if (key) { + const data = { + keyId: key._id, + uid: key.uid, + }; + for (const name of REALM_NAMES) { + data[name + "Id"] = key[name + "Id"]; + } + return res.json(data); + } else { + return res.status(404).json({ + message: "No such key!", + }); + } }); - if (results.deletedCount) { - return res.status(204).send(null); - } else { - return res.status(404).json({ - message: "No keys attached to user!", - }); - } -}); -router.delete("/:keyId", async (req, res) => { - const results = await req.ctx.db.collection("keys").deleteOne({ - _id: {$eq: req.params.keyId}, + router.delete("/by-user", async (req, res) => { + if (typeof req.body.userId != "string") { + return res.status(422).json({ + message: "Missing 'userId'", + }); + } + const results = await req.ctx.db.collection("keys").deleteMany({ + userId: {$eq: req.body.userId}, + }); + if (results.deletedCount) { + return res.status(204).send(null); + } else { + return res.status(404).json({ + message: "No keys attached to user!", + }); + } }); - if (results.deletedCount) { - return res.status(204).send(null); - } else { - return res.status(404).json({ - message: "No keys attached to user!", + + router.delete("/:keyId", async (req, res) => { + const results = await req.ctx.db.collection("keys").deleteOne({ + _id: {$eq: req.params.keyId}, }); - } -}); + if (results.deletedCount) { + return res.status(204).send(null); + } else { + return res.status(404).json({ + message: "No keys attached to user!", + }); + } + }); -export default router; + export default router; diff --git a/routes/memberProjects.js b/routes/memberProjects.js index c2589da..7ee04a4 100644 --- a/routes/memberProjects.js +++ b/routes/memberProjects.js @@ -16,6 +16,7 @@ const ARRAYS = new Set([ router.get("/by-key/:associationId", async (req, res) => { const key = await req.ctx.db.collection("keys").findOne({ [req.associationType]: {$eq: req.params.associationId}, + enabled: { $eq: true }, }); if (!key) { diff --git a/server.js b/server.js index 3f545ac..2af2a77 100644 --- a/server.js +++ b/server.js @@ -89,7 +89,7 @@ connectionPromise.then(async () => { ); app.use((req, res, next) => { res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") return res.sendStatus(204); next(); From 5b546128331c7a48fdb013364582100a2bb62424 Mon Sep 17 00:00:00 2001 From: aln730 Date: Sun, 12 Jul 2026 20:06:50 -0400 Subject: [PATCH 03/15] feat: datadog metrics --- metrics.js | 7 +++++++ package.json | 1 + server.js | 2 ++ 3 files changed, 10 insertions(+) create mode 100644 metrics.js diff --git a/metrics.js b/metrics.js new file mode 100644 index 0000000..3ff3aae --- /dev/null +++ b/metrics.js @@ -0,0 +1,7 @@ +import StatsD from 'hot-shots'; + +export const statsd = new StatsD({ + prefix: 'gatekeeper.', + globalTags: { service: 'gatekeeper-mqtt' }, + errorHandler: (err) => console.error('StatsD error:', err), +}); diff --git a/package.json b/package.json index 46a5ab0..4ac65e9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "body-parser": "^1.19.0", "express": "^4.17.1", + "hot-shots": "^17.0.0", "jose": "^5.0.0", "ldapjs": "^2.3.1", "mongodb": "^7.1.0", diff --git a/server.js b/server.js index 3f545ac..deacc1a 100644 --- a/server.js +++ b/server.js @@ -1,3 +1,4 @@ +import { statsd } from "./metrics.js"; import express from "express"; import mqtt from "mqtt"; import { MongoClient, ObjectId } from "mongodb"; @@ -220,6 +221,7 @@ connectionPromise.then(async () => { } else if (topic.endsWith("/heartbeat")) { const doorId = topic.slice(3, -10); doorHeartbeats.set(doorId, Date.now()); + statsd.increment('door.heartbeat', { door: doorId }); console.log(`ACKing a heartbeat from ${doorId}!`); } }); From ea3e59dc4942d2ed4411a60de05792ab8d9a6434 Mon Sep 17 00:00:00 2001 From: aln730 Date: Sun, 12 Jul 2026 23:22:11 -0400 Subject: [PATCH 04/15] fix: Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c388d55..cee88c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN corepack enable COPY package.json /app/package.json COPY pnpm-lock.yaml /app/pnpm-lock.yaml WORKDIR /app -RUN pnpm i --production=true +RUN pnpm i --production=true --no-optional COPY . /app CMD pnpm run kube From b372fc3ac384a199cf204254c54680749db4699e Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sat, 25 Jul 2026 03:14:27 -0400 Subject: [PATCH 05/15] feat: access route --- middleware/hybridAuth.js | 3 ++- middleware/oidc.js | 3 ++- routes/keys.js | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/middleware/hybridAuth.js b/middleware/hybridAuth.js index f93d69c..ff9e435 100644 --- a/middleware/hybridAuth.js +++ b/middleware/hybridAuth.js @@ -10,9 +10,10 @@ export function hybridAuth(realm) { if (authHeader.startsWith("Bearer ")) { try { - const { userId, groups } = await validateToken(authHeader.slice(7), USER_SCOPE); + const { userId, groups, username } = await validateToken(authHeader.slice(7), USER_SCOPE); req.ctx.userId = userId; req.ctx.groups = groups; + req.ctx.username = username; req.ctx.authMethod = "oidc"; next(); } catch (err) { diff --git a/middleware/oidc.js b/middleware/oidc.js index 7454834..fa83616 100644 --- a/middleware/oidc.js +++ b/middleware/oidc.js @@ -56,7 +56,8 @@ export async function validateToken(token, requiredScope = null) { } const groups = payload.groups ?? []; - return { userId, groups }; + const username = payload.preferred_username + return { userId, groups, username }; } export function oidcAuth(scope) { diff --git a/routes/keys.js b/routes/keys.js index 8b1e57d..85ef9a4 100644 --- a/routes/keys.js +++ b/routes/keys.js @@ -4,6 +4,15 @@ const router = Router(); + router.post("/access", async (req, res) => { + const { reason } = req.body; + if (typeof reason != "string" || !reason.trim()) { + return res.status(422).json({ message: "Missing reason field" }); + } + console.log(`access: ${req.ctx.username} viewed keys || reason: ${reason}`); + res.status(204).send(null); + }); + router.get("/by-user", async (req, res) => { if (typeof req.query.userId != "string") { return res.status(422).json({ message: "Missing 'userId' query param" }); @@ -61,6 +70,9 @@ updates[key] = req.body[key]; } } + + const owner = req.body.username ?? "not found"; + console.log(`access: ${req.ctx.username} updated ${owner}'s key ${req.params.id}`); await req.ctx.db.collection("keys").updateOne( { _id: {$eq: req.params.id}, @@ -116,10 +128,13 @@ }); router.delete("/:keyId", async (req, res) => { + const owner = req.body?.username ?? "unknown"; + const results = await req.ctx.db.collection("keys").deleteOne({ _id: {$eq: req.params.keyId}, }); if (results.deletedCount) { + console.log(`keys: ${req.ctx.username} deleted ${owner}'s key ${req.params.keyId}`); return res.status(204).send(null); } else { return res.status(404).json({ From a104ccac3b47616a81c8ee41bffefe256f67eae6 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sat, 25 Jul 2026 21:10:49 -0400 Subject: [PATCH 06/15] feat: audit route + uid in logEntry + username --- access.js | 6 ++++++ middleware/hybridAuth.js | 5 +++-- middleware/oidc.js | 5 +++-- routes/keys.js | 20 ++++++++++++++++++++ routes/logs.js | 37 ++++++++++++++++++++++++++++++++++++- server.js | 3 +++ 6 files changed, 71 insertions(+), 5 deletions(-) diff --git a/access.js b/access.js index 9e0f839..301d64f 100644 --- a/access.js +++ b/access.js @@ -21,3 +21,9 @@ export async function checkAccess(db, userId, doorId) { ); return groupTicket?.granted; } + +export async function recordAudit(db, entry) { + db.collection("auditLogs").insertOne({ ...entry, timestamp: new Date() }).catch((err) => { + console.error("Failed to write audit entry", err); + }); +} \ No newline at end of file diff --git a/middleware/hybridAuth.js b/middleware/hybridAuth.js index ff9e435..044b53f 100644 --- a/middleware/hybridAuth.js +++ b/middleware/hybridAuth.js @@ -10,10 +10,11 @@ export function hybridAuth(realm) { if (authHeader.startsWith("Bearer ")) { try { - const { userId, groups, username } = await validateToken(authHeader.slice(7), USER_SCOPE); + const { userId, groups, username, name } = await validateToken(authHeader.slice(7), USER_SCOPE); req.ctx.userId = userId; req.ctx.groups = groups; req.ctx.username = username; + req.ctx.name = name; req.ctx.authMethod = "oidc"; next(); } catch (err) { @@ -29,4 +30,4 @@ export function hybridAuth(realm) { next(); } }; -} +} \ No newline at end of file diff --git a/middleware/oidc.js b/middleware/oidc.js index fa83616..267fa8f 100644 --- a/middleware/oidc.js +++ b/middleware/oidc.js @@ -56,8 +56,9 @@ export async function validateToken(token, requiredScope = null) { } const groups = payload.groups ?? []; - const username = payload.preferred_username - return { userId, groups, username }; + const username = payload.preferred_username; + const name = payload.name; + return { userId, groups, username, name }; } export function oidcAuth(scope) { diff --git a/routes/keys.js b/routes/keys.js index 85ef9a4..d5f07fb 100644 --- a/routes/keys.js +++ b/routes/keys.js @@ -1,6 +1,7 @@ import { Router } from "express"; import crypto from "crypto"; import { REALM_NAMES } from "../constants.js"; + import { recordAudit } from "../access.js"; const router = Router(); @@ -10,6 +11,12 @@ return res.status(422).json({ message: "Missing reason field" }); } console.log(`access: ${req.ctx.username} viewed keys || reason: ${reason}`); + await recordAudit(req.ctx.db, { + username: req.ctx.username, + name: req.ctx.name, + action: "Viewed Keys", + reason, + }); res.status(204).send(null); }); @@ -81,6 +88,13 @@ $set: updates, } ); + const change = updates.enabled === false ? "Disabled" : updates.enabled === true ? "Enabled" : "updated"; + await recordAudit(req.ctx.db, { + username: req.ctx.username, + name: req.ctx.name, + action: "Updated Keys", + reason: `${change} ${owner}'s key ${req.params.id}`, + }); res.status(204).send(null); }); @@ -135,6 +149,12 @@ }); if (results.deletedCount) { console.log(`keys: ${req.ctx.username} deleted ${owner}'s key ${req.params.keyId}`); + await recordAudit(req.ctx.db,{ + username: req.ctx.username, + name: req.ctx.name, + action: "Deleted Keys", + reason: `deleted ${owner}'s key ${req.params.keyId}`, + }); return res.status(204).send(null); } else { return res.status(404).json({ diff --git a/routes/logs.js b/routes/logs.js index f25429d..588274c 100644 --- a/routes/logs.js +++ b/routes/logs.js @@ -1,8 +1,30 @@ import { Router } from "express"; +import { recordAudit } from "../access.js"; + const router = Router(); +router.post("/access", async (req, res) => { + const { reason } = req.body; + if (typeof reason != "string" || !reason.trim()) { + return res.status(422).json({ message: "Missing 'reason' field" }); + } + console.log(`access: ${req.ctx.username} viewed logs || reason: ${reason}`); + await recordAudit(req.ctx.db, { + username: req.ctx.username, + name: req.ctx.name, + action: "Viewed Logs", + reason, + }); + res.status(204).send(null); +}); + +router.get("/doors", async (req, res) => { + const doors = await req.ctx.db.collection("accessLogs").distinct("doorName"); + res.json(doors.filter(Boolean)); +}); + router.get("/", async (req, res) => { - const { cursor, since, until } = req.query; + const { cursor, since, until, search, door, granted } = req.query; const query = {}; if (since || until || cursor) { query.timestamp = {}; @@ -11,6 +33,19 @@ router.get("/", async (req, res) => { if (until) query.timestamp.$lte = new Date(until); } + const ands = []; + if (door && door !== "all") { + ands.push({ $or: [{ doorName: door }, { door }] }); + } + if (search) { + const re = new RegExp(search, "i"); + ands.push({ $or: [{ doorName: re }, { door: re }, { username: re }, { name: re }] }); + } + if (ands.length) query.$and = ands; + + if (granted === "granted") query.granted = true; + if (granted === "denied") query.granted = false; + const logs = await req.ctx.db .collection("accessLogs") .find(query) diff --git a/server.js b/server.js index 75a6a66..41b3121 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,7 @@ import keys from "./routes/keys.js"; import users from "./routes/users.js"; import mobile from "./routes/mobile.js"; import logs from "./routes/logs.js"; +import audit from "./routes/audit.js"; //fetch user from the https endpoint async function fetchUser(endpoint, token, memberProjectsId) { @@ -127,6 +128,7 @@ connectionPromise.then(async () => { app.use("/admin/keys", hybridAuth("admin"), requireGroup("rtp"), keys); app.use("/admin/users", hybridAuth("admin"), requireGroup("rtp"), users); app.use("/admin/logs", hybridAuth("admin"), requireGroup("rtp"), logs); + app.use("/admin/audit", hybridAuth("admin"), requireGroup("rtp"), audit); app.use("/mobile", mobile); client.on("connect", async () => { @@ -199,6 +201,7 @@ connectionPromise.then(async () => { name, doorsId: payload.association, keyId: key._id, + uid: key.uid ?? null, granted: !!granted, }; From cfc75b176a03f68f2e58adb17327a508af0e7685 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sat, 25 Jul 2026 21:11:21 -0400 Subject: [PATCH 07/15] feat: audit route (mostly stuff from logs route but for audit logs) --- routes/audit.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 routes/audit.js diff --git a/routes/audit.js b/routes/audit.js new file mode 100644 index 0000000..fbf90ac --- /dev/null +++ b/routes/audit.js @@ -0,0 +1,34 @@ +import { Router } from "express"; +const router = Router(); + +router.get("/", async (req, res) => { + const { cursor, search, action } = req.query; + const query = {}; + + if (cursor) { + query.timestamp = { $lt: new Date(cursor) }; + } + + const ands = []; + if (action && action !== "all") ands.push({ action }); + if (search) { + const re = new RegExp(search, "i"); + ands.push({ $or: [{ username: re }, { name: re }, { reason: re }] }); + } + if (ands.length) query.$and = ands; + + const entries = await req.ctx.db + .collection("auditLogs") + .find(query) + .sort({ timestamp: -1 }) + .limit(50) + .toArray(); + + const nextCursor = entries.length === 50 + ? entries[entries.length - 1].timestamp.toISOString() + : null; + + res.json({ entries, cursor: nextCursor }); +}); + +export default router; \ No newline at end of file From 313b1849d2343d2924c6fbd8a3d7eb77370d3fc9 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sat, 25 Jul 2026 21:36:38 -0400 Subject: [PATCH 08/15] docker stuff --- .dockerignore | 4 ++++ Dockerfile | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c8ddf6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.git +*.log +.env diff --git a/Dockerfile b/Dockerfile index cee88c7..88fcc05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,4 @@ WORKDIR /app RUN pnpm i --production=true --no-optional COPY . /app -CMD pnpm run kube +CMD ["pnpm", "run", "kube"] From e0de99a675cc3075f5d9bdc9128a3d3772792e41 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sat, 25 Jul 2026 21:52:13 -0400 Subject: [PATCH 09/15] docs: new routes --- docs.org | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs.org b/docs.org index bced348..609e791 100644 --- a/docs.org +++ b/docs.org @@ -17,17 +17,30 @@ *** GET /admin/users/uuid-by-uid/:uid Fetch user UUID by CSH username ** /admin/keys +*** POST /admin/keys/access + State a reason to view keys +*** GET /admin/keys/by-user + Fetch keys attached to a user *** PUT /admin/keys Add a key for a user *** PATCH /admin/keys/:id Update a key +*** GET /admin/keys/by-association/:id + Fetch key by ID *** DELETE /admin/keys/by-user Delete all keys attached to a user *** DELETE /admin/keys/:keyId Delete a key ** /admin/logs +*** POST /admin/logs/access + State a reason to view logs *** GET /admin/logs Fetch paginated access logs +*** GET /admin/logs/doors + List distinct doors that appear in access logs +** /admin/audit +*** GET /admin/audit + Fetch paginated audit logs * /projects ** GET /projects/by-key/:keyId - Fetches user details from LDAP attached to the key with the given ID (from the member project realm) + Fetches user details from LDAP attached to the key with the given ID (from the member project realm) \ No newline at end of file From 7da12c8972b5c808870419044f966ce886c274f5 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Mon, 27 Jul 2026 21:30:15 -0400 Subject: [PATCH 10/15] feat: accessLog expires after 6 months --- server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server.js b/server.js index 41b3121..37f7a09 100644 --- a/server.js +++ b/server.js @@ -49,6 +49,7 @@ connectionPromise.then(async () => { db.collection("keys").createIndex("drinkId", {unique: true}), db.collection("keys").createIndex("memberProjectsId", {unique: true}), db.collection("accessLogs").createIndex({ timestamp: -1 }), + db.collection("accessLogs").createIndex({ timestamp: 1 }, { expireAfterSeconds: 15778476 }), ]); async function scheduledTasks() { From 6a6b81f8ddced98ca86baeab96317fd16cefc272 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Tue, 28 Jul 2026 03:10:07 -0400 Subject: [PATCH 11/15] feat: rejects request if door is offline --- routes/doors.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/routes/doors.js b/routes/doors.js index 820e0e7..049aa4d 100644 --- a/routes/doors.js +++ b/routes/doors.js @@ -4,20 +4,24 @@ import { checkAccess } from "../access.js"; const router = Router(); -router.get("/:doorId/status", (req, res) => { +function getDoorStatus(doorId){ // If it's been more than 1 minute, we assume something is broken... - const lastHeartbeat = doorHeartbeats.get(req.params.doorId); + const lastHeartbeat = doorHeartbeats.get(doorId); if (lastHeartbeat) { - res.json({ + return { guess: Date.now() - lastHeartbeat > 1000 * 60 ? "offline" : "online", lastHeartbeat, - }); + }; } else { - res.json({ + return { guess: "offline", lastHeartbeat: 0, - }); + }; } +} + +router.get("/:doorId/status", (req, res) => { + res.json(getDoorStatus(req.params.doorId)); }); router.get("/", async (req, res) => { @@ -46,6 +50,11 @@ router.post("/:doorId/unlock", async (req, res) => { return res.status(403).json({ message: "Access denied" }); } } + + if (getDoorStatus(req.params.doorId).guess === "offline") { + return res.status(409).json({ message: "Door is offline" }); + } + req.ctx.mqtt.publish(`gk/${req.params.doorId}/unlock`, ""); res.status(204).send(null); }); From 6f1d8f21938e54fcf457480367ce93abd7883183 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sun, 2 Aug 2026 12:47:35 -0400 Subject: [PATCH 12/15] feat: logs for remote access --- access.js | 20 ++++++++++++++++- routes/doors.js | 20 ++++++++++++++++- routes/keys.js | 60 ++++++++++++++++++++++++------------------------- 3 files changed, 68 insertions(+), 32 deletions(-) diff --git a/access.js b/access.js index 301d64f..bd7a636 100644 --- a/access.js +++ b/access.js @@ -23,7 +23,25 @@ export async function checkAccess(db, userId, doorId) { } export async function recordAudit(db, entry) { - db.collection("auditLogs").insertOne({ ...entry, timestamp: new Date() }).catch((err) => { + return db.collection("auditLogs").insertOne({ ...entry, timestamp: new Date() }).catch((err) => { console.error("Failed to write audit entry", err); + throw err; + }); +} + +export async function recordDoorUnlock(db, { doorId, doorName, username, name }) { + return db.collection("accessLogs").insertOne({ + timestamp: new Date(), + door: doorId, + doorName: doorName, + username, + name: name, + doorsId: null, + keyId: "Remote Access", + uid: null, + granted: true, + }).catch((err) => { + console.error("Failed to write accessLogs", err); + throw err; }); } \ No newline at end of file diff --git a/routes/doors.js b/routes/doors.js index 049aa4d..e8b9fd9 100644 --- a/routes/doors.js +++ b/routes/doors.js @@ -1,6 +1,7 @@ import { Router } from "express"; import { doorHeartbeats } from "../state.js"; -import { checkAccess } from "../access.js"; +import { checkAccess, recordDoorUnlock } from "../access.js"; +import { ObjectId } from "mongodb"; const router = Router(); @@ -55,6 +56,23 @@ router.post("/:doorId/unlock", async (req, res) => { return res.status(409).json({ message: "Door is offline" }); } + if (req.ctx.authMethod === "oidc") { + const doorId = req.params.doorId; + const doorDoc = await req.ctx.db.collection("doors").findOne({ + $or: [ + { _id: doorId }, + ...(ObjectId.isValid(doorId) ? [{ _id: new ObjectId(doorId) }] : []), + ], + }); + + await recordDoorUnlock(req.ctx.db, { + doorId: req.params.doorId, + doorName: doorDoc?.name, + username: req.ctx.username ?? req.ctx.userId, + name: req.ctx.name, + }); + } + req.ctx.mqtt.publish(`gk/${req.params.doorId}/unlock`, ""); res.status(204).send(null); }); diff --git a/routes/keys.js b/routes/keys.js index d5f07fb..57c1321 100644 --- a/routes/keys.js +++ b/routes/keys.js @@ -1,36 +1,36 @@ - import { Router } from "express"; - import crypto from "crypto"; - import { REALM_NAMES } from "../constants.js"; - import { recordAudit } from "../access.js"; +import { Router } from "express"; +import crypto from "crypto"; +import { REALM_NAMES } from "../constants.js"; +import { recordAudit } from "../access.js"; - const router = Router(); +const router = Router(); - router.post("/access", async (req, res) => { - const { reason } = req.body; - if (typeof reason != "string" || !reason.trim()) { - return res.status(422).json({ message: "Missing reason field" }); - } - console.log(`access: ${req.ctx.username} viewed keys || reason: ${reason}`); - await recordAudit(req.ctx.db, { - username: req.ctx.username, - name: req.ctx.name, - action: "Viewed Keys", - reason, - }); - res.status(204).send(null); - }); - - router.get("/by-user", async (req, res) => { - if (typeof req.query.userId != "string") { - return res.status(422).json({ message: "Missing 'userId' query param" }); +router.post("/access", async (req, res) => { + const { reason } = req.body; + if (typeof reason != "string" || !reason.trim()) { + return res.status(422).json({ message: "Missing reason field" }); } - const keys = await req.ctx.db.collection("keys").find({ userId: req.query.userId }).toArray(); - res.json(keys); - }); - - // First, PUT /keys with details of user key is for - // Receive a keyId back which is our association - // Register key using association and send back the now-randomised UID + console.log(`access: ${req.ctx.username} viewed keys || reason: ${reason}`); + await recordAudit(req.ctx.db, { + username: req.ctx.username, + name: req.ctx.name, + action: "Viewed Keys", + reason, + }); + res.status(204).send(null); +}); + +router.get("/by-user", async (req, res) => { + if (typeof req.query.userId != "string") { + return res.status(422).json({ message: "Missing 'userId' query param" }); + } + const keys = await req.ctx.db.collection("keys").find({ userId: req.query.userId }).toArray(); + res.json(keys); +}); + +// First, PUT /keys with details of user key is for +// Receive a keyId back which is our association +// Register key using association and send back the now-randomised UID // with PATCH /keys/:id router.put("/", async (req, res) => { From 2e907fafb3d49d4890b9c1585c83f9d60fd8fa5a Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sun, 2 Aug 2026 17:37:17 -0400 Subject: [PATCH 13/15] fix: updated the hacky stuff in door docs, updated the field in recordDoorUnlock, and added a function specifically for checking if door is offline --- access.js | 2 +- routes/doors.js | 17 +++++++++-------- server.js | 9 +-------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/access.js b/access.js index bd7a636..056457b 100644 --- a/access.js +++ b/access.js @@ -37,7 +37,7 @@ export async function recordDoorUnlock(db, { doorId, doorName, username, name }) username, name: name, doorsId: null, - keyId: "Remote Access", + keyId: null, uid: null, granted: true, }).catch((err) => { diff --git a/routes/doors.js b/routes/doors.js index e8b9fd9..5a4c35c 100644 --- a/routes/doors.js +++ b/routes/doors.js @@ -21,6 +21,12 @@ function getDoorStatus(doorId){ } } +function isDoorOffline(doorId) { + const lastHeartbeat = doorHeartbeats.get(doorId); + if (!lastHeartbeat) return true; + return Date.now() - lastHeartbeat > 1000 * 60; +} + router.get("/:doorId/status", (req, res) => { res.json(getDoorStatus(req.params.doorId)); }); @@ -52,18 +58,13 @@ router.post("/:doorId/unlock", async (req, res) => { } } - if (getDoorStatus(req.params.doorId).guess === "offline") { - return res.status(409).json({ message: "Door is offline" }); + if (isDoorOffline(req.params.doorId)) { + return res.status(400).json({ message: "Door is offline" }); } if (req.ctx.authMethod === "oidc") { const doorId = req.params.doorId; - const doorDoc = await req.ctx.db.collection("doors").findOne({ - $or: [ - { _id: doorId }, - ...(ObjectId.isValid(doorId) ? [{ _id: new ObjectId(doorId) }] : []), - ], - }); + const doorDoc = await req.ctx.db.collection("doors").findOne({ _id: req.params.doorId }); await recordDoorUnlock(req.ctx.db, { doorId: req.params.doorId, diff --git a/server.js b/server.js index 37f7a09..21c3720 100644 --- a/server.js +++ b/server.js @@ -173,14 +173,7 @@ connectionPromise.then(async () => { return {}; }) : Promise.resolve({}), - db.collection("doors").findOne({ - $or: [ - { _id: doorId }, - ...(ObjectId.isValid(doorId) - ? [{ _id: new ObjectId(doorId) }] - : []), - ], - }), + db.collection("doors").findOne({ _id: doorId }), checkAccess(db, key.userId, doorId), ]); From a0f10b2fc0004910b1ce8f43ba20cb9a86da4bd9 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sun, 2 Aug 2026 18:07:23 -0400 Subject: [PATCH 14/15] also we dont need console logs for doors logs anymore --- server.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/server.js b/server.js index 21c3720..045a42e 100644 --- a/server.js +++ b/server.js @@ -204,17 +204,6 @@ connectionPromise.then(async () => { console.error("Failed to insert into DB", err); }); - - if (granted) { - console.log( - `[${timestamp}] ${name} (${username}) is unlocking ${doorName || doorId}` - ); - client.publish(`gk/${doorId}/unlock`); - } else { - console.log( - `[${timestamp}] Attempted unlock of ${doorName || doorId} by ${name} (${username})! Not allowed...` - ); - } } else if (topic.endsWith("/heartbeat")) { const doorId = topic.slice(3, -10); doorHeartbeats.set(doorId, Date.now()); From edee29f414ad0d98272ee22c8ef3f772d0b80f02 Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Wed, 5 Aug 2026 12:44:38 -0400 Subject: [PATCH 15/15] refactor stuff --- access.js | 24 ---------------- routes/audit.js | 7 +++++ routes/doors.js | 73 ++++++++++++++++++++++--------------------------- routes/keys.js | 2 +- routes/logs.js | 20 +++++++++++++- server.js | 20 ++++++-------- 6 files changed, 68 insertions(+), 78 deletions(-) diff --git a/access.js b/access.js index 056457b..9e0f839 100644 --- a/access.js +++ b/access.js @@ -21,27 +21,3 @@ export async function checkAccess(db, userId, doorId) { ); return groupTicket?.granted; } - -export async function recordAudit(db, entry) { - return db.collection("auditLogs").insertOne({ ...entry, timestamp: new Date() }).catch((err) => { - console.error("Failed to write audit entry", err); - throw err; - }); -} - -export async function recordDoorUnlock(db, { doorId, doorName, username, name }) { - return db.collection("accessLogs").insertOne({ - timestamp: new Date(), - door: doorId, - doorName: doorName, - username, - name: name, - doorsId: null, - keyId: null, - uid: null, - granted: true, - }).catch((err) => { - console.error("Failed to write accessLogs", err); - throw err; - }); -} \ No newline at end of file diff --git a/routes/audit.js b/routes/audit.js index fbf90ac..b6b275e 100644 --- a/routes/audit.js +++ b/routes/audit.js @@ -1,6 +1,13 @@ import { Router } from "express"; const router = Router(); +export async function recordAudit(db, entry) { + return db.collection("auditLogs").insertOne({ ...entry, timestamp: new Date() }).catch((err) => { + console.error("Failed to write audit entry", err); + throw err; + }); +} + router.get("/", async (req, res) => { const { cursor, search, action } = req.query; const query = {}; diff --git a/routes/doors.js b/routes/doors.js index 5a4c35c..be0753c 100644 --- a/routes/doors.js +++ b/routes/doors.js @@ -1,41 +1,34 @@ import { Router } from "express"; import { doorHeartbeats } from "../state.js"; -import { checkAccess, recordDoorUnlock } from "../access.js"; -import { ObjectId } from "mongodb"; +import { checkAccess } from "../access.js"; +import { recordDoorUnlock } from "./logs.js"; const router = Router(); -function getDoorStatus(doorId){ - // If it's been more than 1 minute, we assume something is broken... - const lastHeartbeat = doorHeartbeats.get(doorId); - if (lastHeartbeat) { - return { - guess: Date.now() - lastHeartbeat > 1000 * 60 ? "offline" : "online", - lastHeartbeat, - }; - } else { - return { - guess: "offline", - lastHeartbeat: 0, - }; - } -} - function isDoorOffline(doorId) { + // If it's been more than 1 minute, we assume something is broken... const lastHeartbeat = doorHeartbeats.get(doorId); if (!lastHeartbeat) return true; return Date.now() - lastHeartbeat > 1000 * 60; } +function getDoorStatus(doorId) { + const lastHeartbeat = doorHeartbeats.get(doorId); + return { + guess: isDoorOffline(doorId) ? "offline" : "online", + lastHeartbeat: lastHeartbeat || 0, + }; +} + router.get("/:doorId/status", (req, res) => { res.json(getDoorStatus(req.params.doorId)); }); router.get("/", async (req, res) => { const doors = await req.ctx.db.collection("doors").find({}).toArray(); - const accessResults = req.ctx.authMethod === "oidc" + const accessResults = req.ctx.userId ? await Promise.all(doors.map((d) => checkAccess(req.ctx.db, req.ctx.userId, String(d._id)))) - : doors.map(() => true); + : doors.map(() => false); res.json({ doors: doors.map((door, i) => ({ @@ -47,32 +40,32 @@ router.get("/", async (req, res) => { }); router.post("/:doorId/unlock", async (req, res) => { - if (req.ctx.authMethod === "oidc") { - const granted = await checkAccess( - req.ctx.db, - req.ctx.userId, - req.params.doorId - ); - if (!granted) { - return res.status(403).json({ message: "Access denied" }); - } + if (!req.ctx.userId) { //auth method should always have an user identity + return res.status(403).json({ message: "Access denied" }); + } + + const granted = await checkAccess( + req.ctx.db, + req.ctx.userId, + req.params.doorId + ); + + if (!granted) { + return res.status(403).json({ message: "Access denied" }); } if (isDoorOffline(req.params.doorId)) { return res.status(400).json({ message: "Door is offline" }); } - if (req.ctx.authMethod === "oidc") { - const doorId = req.params.doorId; - const doorDoc = await req.ctx.db.collection("doors").findOne({ _id: req.params.doorId }); - - await recordDoorUnlock(req.ctx.db, { - doorId: req.params.doorId, - doorName: doorDoc?.name, - username: req.ctx.username ?? req.ctx.userId, - name: req.ctx.name, - }); - } + const doorDoc = await req.ctx.db.collection("doors").findOne({ _id: req.params.doorId }); + await recordDoorUnlock(req.ctx.db, { + doorId: req.params.doorId, + doorName: doorDoc?.name, + username: req.ctx.username ?? req.ctx.userId, + name: req.ctx.name, + accessType: req.ctx.authMethod + }); req.ctx.mqtt.publish(`gk/${req.params.doorId}/unlock`, ""); res.status(204).send(null); diff --git a/routes/keys.js b/routes/keys.js index 57c1321..564c874 100644 --- a/routes/keys.js +++ b/routes/keys.js @@ -1,7 +1,7 @@ import { Router } from "express"; import crypto from "crypto"; import { REALM_NAMES } from "../constants.js"; -import { recordAudit } from "../access.js"; +import { recordAudit } from "./audit.js"; const router = Router(); diff --git a/routes/logs.js b/routes/logs.js index 588274c..ba8dcab 100644 --- a/routes/logs.js +++ b/routes/logs.js @@ -1,8 +1,26 @@ import { Router } from "express"; -import { recordAudit } from "../access.js"; +import { recordAudit } from "./audit.js"; const router = Router(); +export async function recordDoorUnlock(db, { doorId, doorName, username, name, doorsId = null, keyId = null, uid = null, granted = true }) { + return db.collection("accessLogs").insertOne({ + timestamp: new Date(), + door: doorId, + doorName, + username, + name, + doorsId, + keyId, + uid, + granted, + accessType: accessMethod({ keyId, uid }), + }).catch((err) => { + console.error("Failed to write accessLogs", err); + throw err; + }); +} + router.post("/access", async (req, res) => { const { reason } = req.body; if (typeof reason != "string" || !reason.trim()) { diff --git a/server.js b/server.js index 045a42e..f016c47 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,7 @@ import { statsd } from "./metrics.js"; import express from "express"; import mqtt from "mqtt"; -import { MongoClient, ObjectId } from "mongodb"; +import { MongoClient } from "mongodb"; import bodyParser from "body-parser"; import morgan from "morgan"; @@ -11,6 +11,7 @@ import auth from "./auth.js"; import { hybridAuth } from "./middleware/hybridAuth.js"; import { checkAccess } from "./access.js"; import { requireGroup } from "./middleware/oidc.js"; +import { recordDoorUnlock } from "./routes/logs.js" // API routes import memberProjects from "./routes/memberProjects.js"; @@ -182,14 +183,11 @@ connectionPromise.then(async () => { const name = user.cn || null; // Resolve door name const doorName = doorDoc?.name || null; - - //timestamps (DUHHH?) - const timestamp = new Date(); + const accessType = key.uid ? "physical" : "mobile"; // Structured log - const logEntry = { - timestamp, - door: doorId, + await recordDoorUnlock(db, { + doorId, doorName, username, name, @@ -197,11 +195,9 @@ connectionPromise.then(async () => { keyId: key._id, uid: key.uid ?? null, granted: !!granted, - }; - - console.log(logEntry); - db.collection("accessLogs").insertOne(logEntry).catch((err) => { - console.error("Failed to insert into DB", err); + accessType, + }).catch((err) => { + console.error("Failed to insert into accessLogs", err); }); } else if (topic.endsWith("/heartbeat")) {