Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 19 additions & 1 deletion access.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: null,
uid: null,
granted: true,
}).catch((err) => {
console.error("Failed to write accessLogs", err);
throw err;
});
}
42 changes: 35 additions & 7 deletions routes/doors.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
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();

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,
});
};
}
}
Comment thread
aln730 marked this conversation as resolved.
Comment on lines +8 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this use isDoorOffline too so that all that logic is in one spot?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call


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));
});

router.get("/", async (req, res) => {
Expand Down Expand Up @@ -46,6 +57,23 @@ router.post("/:doorId/unlock", async (req, res) => {
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,
});
Comment on lines +69 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we only recording when a door is unlocked when the user is authed with SSO and not any other possible method?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh okay it seems like recordDoorUnlock is really only intended for the web. But I guess the question still stands, is there a reason we don't want to record all unlocks in the audit logs?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Physical door taps are logged separately in server.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as a console.log?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it is inserted into the collection. It still has a console.log which I was using for debugging. That can be cleaned

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They get logged in the db, if you change the date range locally or on gatekeeper.csh to be when doors were online and people were on floor you can see them

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I understand this a little better now, but I'm still confused. It seems like there are only two auth methods: secret and oidc. Why would we not want to log for both (and other methods if we ever add them)?

@aln730 aln730 Aug 4, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secret is meant for non-oidc api calls using secrets like GK_DRINK_SECRETS for Drink. It shouldn't be used for unlocking doors because there is no identity attached to it. Good catch W.

What we can do instead is check for userId rather than checking for oidc and deny secret from being used and it also leaves room to add more auth methods later. Physical access is handled via MQTT so there won't be any issue there. Thoughts?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that all sounds good. Unconditional log on this endpoint is a must IMO either way

}

req.ctx.mqtt.publish(`gk/${req.params.doorId}/unlock`, "");
res.status(204).send(null);
});
Expand Down
60 changes: 30 additions & 30 deletions routes/keys.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
20 changes: 1 addition & 19 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]);

Expand Down Expand Up @@ -211,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());
Expand Down