feat(security): add phase8 hardening with rate limits, audit logs, and auth-first simulator flow
This commit is contained in:
66
Backend/routes/audit.ts
Normal file
66
Backend/routes/audit.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '../db/client';
|
||||
import { auditLogs } from '../db/schema';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const querySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
action: z.string().optional(),
|
||||
});
|
||||
|
||||
router.get('/me', requireDeviceAuth, async (req, res) => {
|
||||
const parsed = querySchema.safeParse(req.query);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.query.auditLogs.findMany({
|
||||
where: eq(auditLogs.ownerUserId, deviceAuth.userId),
|
||||
orderBy: [desc(auditLogs.createdAt)],
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
const filtered = parsed.data.action ? result.filter((item) => item.action === parsed.data.action) : result;
|
||||
|
||||
res.json({ count: filtered.length, logs: filtered });
|
||||
});
|
||||
|
||||
router.get('/device', requireDeviceAuth, async (req, res) => {
|
||||
const parsed = querySchema.safeParse(req.query);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.query.auditLogs.findMany({
|
||||
where: and(eq(auditLogs.ownerUserId, deviceAuth.userId), eq(auditLogs.actorDeviceId, deviceAuth.deviceId)),
|
||||
orderBy: [desc(auditLogs.createdAt)],
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
res.json({ count: result.length, logs: result });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,6 +7,7 @@ import { deviceLinks, devices, events, notifications } from '../db/schema';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { sendRealtimeToDevice } from '../realtime/gateway';
|
||||
import { writeAuditLog } from '../services/audit';
|
||||
import { enqueuePushNotification } from '../services/push';
|
||||
|
||||
const router = Router();
|
||||
@@ -126,6 +127,16 @@ router.post('/motion/start', requireDeviceAuth, async (req, res) => {
|
||||
event,
|
||||
notifiedClients: activeLinks.length,
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
ownerUserId: deviceAuth.userId,
|
||||
actorDeviceId: cameraDevice.id,
|
||||
action: 'event.motion_started',
|
||||
targetType: 'event',
|
||||
targetId: event.id,
|
||||
metadata: { notifiedClients: activeLinks.length },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:eventId/motion/end', requireDeviceAuth, async (req, res) => {
|
||||
@@ -210,6 +221,16 @@ router.post('/:eventId/motion/end', requireDeviceAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({ message: 'Motion event ended', event: updated, notifiedClients: activeLinks.length });
|
||||
|
||||
await writeAuditLog({
|
||||
ownerUserId: deviceAuth.userId,
|
||||
actorDeviceId: deviceAuth.deviceId,
|
||||
action: 'event.motion_ended',
|
||||
targetType: 'event',
|
||||
targetId: event.id,
|
||||
metadata: { status: parsed.data.status, notifiedClients: activeLinks.length },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
import { db } from '../db/client';
|
||||
import { recordings, streamSessions } from '../db/schema';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { writeAuditLog } from '../services/audit';
|
||||
import { minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio';
|
||||
|
||||
const router = Router();
|
||||
@@ -105,6 +106,16 @@ router.post('/:recordingId/finalize', requireDeviceAuth, async (req, res) => {
|
||||
.returning();
|
||||
|
||||
res.json({ message: 'Recording finalized', recording: updated });
|
||||
|
||||
await writeAuditLog({
|
||||
ownerUserId: recording.ownerUserId,
|
||||
actorDeviceId: recording.cameraDeviceId,
|
||||
action: 'recording.finalized',
|
||||
targetType: 'recording',
|
||||
targetId: recording.id,
|
||||
metadata: { objectKey: parsed.data.objectKey, bucket: parsed.data.bucket },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) => {
|
||||
@@ -156,6 +167,16 @@ router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) =>
|
||||
downloadUrl,
|
||||
expiresInSeconds: minioPresignedExpirySeconds,
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
ownerUserId: recording.ownerUserId,
|
||||
actorDeviceId: deviceAuth.deviceId,
|
||||
action: 'recording.download_url_issued',
|
||||
targetType: 'recording',
|
||||
targetId: recording.id,
|
||||
metadata: { objectKey: recording.objectKey, bucket: recording.bucket },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
// Internal helper used by stream lifecycle to create recording placeholder rows.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { deviceCommands, deviceLinks, devices, streamSessions } from '../db/sche
|
||||
import { mediaProvider } from '../media/service';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { dispatchCommandById, sendRealtimeToDevice } from '../realtime/gateway';
|
||||
import { writeAuditLog } from '../services/audit';
|
||||
import { enqueuePushNotification } from '../services/push';
|
||||
import { createRecordingForStream } from './recordings';
|
||||
|
||||
@@ -192,6 +193,16 @@ router.post('/request', requireDeviceAuth, async (req, res) => {
|
||||
streamSession: session,
|
||||
command: refreshedCommand ?? command,
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
ownerUserId: sourceDevice.userId,
|
||||
actorDeviceId: sourceDevice.id,
|
||||
action: 'stream.requested',
|
||||
targetType: 'stream_session',
|
||||
targetId: session.id,
|
||||
metadata: { cameraDeviceId: cameraDevice.id, reason: session.reason },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
|
||||
@@ -287,6 +298,16 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({ message: 'Stream accepted', streamSession: updated });
|
||||
|
||||
await writeAuditLog({
|
||||
ownerUserId: session.ownerUserId,
|
||||
actorDeviceId: session.cameraDeviceId,
|
||||
action: 'stream.accepted',
|
||||
targetType: 'stream_session',
|
||||
targetId: session.id,
|
||||
metadata: { mediaSessionId: updated.mediaSessionId, mediaProvider: updated.mediaProvider },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:streamSessionId/publish-credentials', requireDeviceAuth, async (req, res) => {
|
||||
@@ -330,6 +351,16 @@ router.get('/:streamSessionId/publish-credentials', requireDeviceAuth, async (re
|
||||
});
|
||||
|
||||
res.json(credentials);
|
||||
|
||||
await writeAuditLog({
|
||||
ownerUserId: session.ownerUserId,
|
||||
actorDeviceId: session.cameraDeviceId,
|
||||
action: 'stream.publish_credentials_issued',
|
||||
targetType: 'stream_session',
|
||||
targetId: session.id,
|
||||
metadata: { mediaProvider: credentials.provider },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:streamSessionId/subscribe-credentials', requireDeviceAuth, async (req, res) => {
|
||||
@@ -376,6 +407,16 @@ router.get('/:streamSessionId/subscribe-credentials', requireDeviceAuth, async (
|
||||
});
|
||||
|
||||
res.json(credentials);
|
||||
|
||||
await writeAuditLog({
|
||||
ownerUserId: session.ownerUserId,
|
||||
actorDeviceId: deviceAuth.deviceId,
|
||||
action: 'stream.subscribe_credentials_issued',
|
||||
targetType: 'stream_session',
|
||||
targetId: session.id,
|
||||
metadata: { mediaProvider: credentials.provider },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user