feat(security): add phase8 hardening with rate limits, audit logs, and auth-first simulator flow
This commit is contained in:
@@ -150,6 +150,18 @@ export const pushNotifications = pgTable('push_notifications', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const auditLogs = pgTable('audit_logs', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id),
|
||||||
|
actorDeviceId: uuid('actor_device_id').references(() => devices.id),
|
||||||
|
action: varchar('action', { length: 128 }).notNull(),
|
||||||
|
targetType: varchar('target_type', { length: 64 }).notNull(),
|
||||||
|
targetId: varchar('target_id', { length: 255 }).notNull(),
|
||||||
|
metadata: jsonb('metadata').$type<Record<string, unknown> | null>().default(null),
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const accounts = pgTable('account', {
|
export const accounts = pgTable('account', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
userId: uuid('user_id').notNull().references(() => users.id),
|
userId: uuid('user_id').notNull().references(() => users.id),
|
||||||
@@ -197,6 +209,7 @@ export const schema = {
|
|||||||
videos,
|
videos,
|
||||||
notifications,
|
notifications,
|
||||||
pushNotifications,
|
pushNotifications,
|
||||||
|
auditLogs,
|
||||||
accounts,
|
accounts,
|
||||||
sessions,
|
sessions,
|
||||||
verifications,
|
verifications,
|
||||||
|
|||||||
14
Backend/drizzle/0011_security_audit_logs.sql
Normal file
14
Backend/drizzle/0011_security_audit_logs.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"owner_user_id" uuid NOT NULL,
|
||||||
|
"actor_device_id" uuid,
|
||||||
|
"action" varchar(128) NOT NULL,
|
||||||
|
"target_type" varchar(64) NOT NULL,
|
||||||
|
"target_id" varchar(255) NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT 'null'::jsonb,
|
||||||
|
"ip_address" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_device_id_devices_id_fk" FOREIGN KEY ("actor_device_id") REFERENCES "public"."devices"("id") ON DELETE no action ON UPDATE no action;
|
||||||
@@ -78,6 +78,13 @@
|
|||||||
"when": 1770417956419,
|
"when": 1770417956419,
|
||||||
"tag": "0010_push_notifications_queue",
|
"tag": "0010_push_notifications_queue",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1770418956419,
|
||||||
|
"tag": "0011_security_audit_logs",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { toNodeHandler } from 'better-auth/node';
|
import { toNodeHandler } from 'better-auth/node';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
|
|
||||||
import { auth } from './auth';
|
import { auth } from './auth';
|
||||||
@@ -14,6 +16,8 @@ import eventsRoutes from './routes/events';
|
|||||||
import streamsRoutes from './routes/streams';
|
import streamsRoutes from './routes/streams';
|
||||||
import recordingsRoutes from './routes/recordings';
|
import recordingsRoutes from './routes/recordings';
|
||||||
import pushNotificationsRoutes from './routes/push-notifications';
|
import pushNotificationsRoutes from './routes/push-notifications';
|
||||||
|
import auditRoutes from './routes/audit';
|
||||||
|
import { rateLimit } from './middleware/security';
|
||||||
import { setupRealtimeGateway } from './realtime/gateway';
|
import { setupRealtimeGateway } from './realtime/gateway';
|
||||||
import { ensureMinioBucket } from './utils/minio';
|
import { ensureMinioBucket } from './utils/minio';
|
||||||
import { startRecordingsWorker } from './workers/recordings';
|
import { startRecordingsWorker } from './workers/recordings';
|
||||||
@@ -21,6 +25,9 @@ import { startPushWorker } from './services/push';
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const openApiDocument = buildOpenApiDocument();
|
const openApiDocument = buildOpenApiDocument();
|
||||||
|
const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||||
|
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
app.get('/', (_req, res) => {
|
app.get('/', (_req, res) => {
|
||||||
res.send('API is running');
|
res.send('API is running');
|
||||||
@@ -34,17 +41,26 @@ app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));
|
|||||||
|
|
||||||
app.all('/api/auth/*splat', toNodeHandler(auth));
|
app.all('/api/auth/*splat', toNodeHandler(auth));
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: trustedOrigins.length > 0 ? trustedOrigins : true,
|
||||||
|
credentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/sim', express.static('public'));
|
app.use('/sim', express.static('public'));
|
||||||
app.use('/videos', videosRoutes);
|
app.use('/videos', videosRoutes);
|
||||||
app.use('/admin', adminRoutes);
|
app.use('/admin', adminRoutes);
|
||||||
app.use('/devices', devicesRoutes);
|
app.use('/devices', rateLimit({ keyPrefix: 'devices', windowMs: 60_000, max: 120 }), devicesRoutes);
|
||||||
app.use('/device-links', deviceLinksRoutes);
|
app.use('/device-links', deviceLinksRoutes);
|
||||||
app.use('/commands', commandsRoutes);
|
app.use('/commands', rateLimit({ keyPrefix: 'commands', windowMs: 60_000, max: 120 }), commandsRoutes);
|
||||||
app.use('/events', eventsRoutes);
|
app.use('/events', rateLimit({ keyPrefix: 'events', windowMs: 60_000, max: 120 }), eventsRoutes);
|
||||||
app.use('/streams', streamsRoutes);
|
app.use('/streams', rateLimit({ keyPrefix: 'streams', windowMs: 60_000, max: 120 }), streamsRoutes);
|
||||||
app.use('/recordings', recordingsRoutes);
|
app.use('/recordings', rateLimit({ keyPrefix: 'recordings', windowMs: 60_000, max: 120 }), recordingsRoutes);
|
||||||
app.use('/push-notifications', pushNotificationsRoutes);
|
app.use('/push-notifications', pushNotificationsRoutes);
|
||||||
|
app.use('/audit', auditRoutes);
|
||||||
|
|
||||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
31
Backend/middleware/security.ts
Normal file
31
Backend/middleware/security.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
count: number;
|
||||||
|
windowStart: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
export const rateLimit = (options: { keyPrefix: string; windowMs: number; max: number }) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const key = `${options.keyPrefix}:${req.ip ?? 'unknown'}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const current = buckets.get(key);
|
||||||
|
|
||||||
|
if (!current || now - current.windowStart > options.windowMs) {
|
||||||
|
buckets.set(key, { count: 1, windowStart: now });
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.count >= options.max) {
|
||||||
|
res.status(429).json({ message: 'Rate limit exceeded. Try again later.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.count += 1;
|
||||||
|
buckets.set(key, current);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -123,6 +123,25 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Auth</h2>
|
||||||
|
<label>Name</label>
|
||||||
|
<input id="authName" placeholder="optional display name" />
|
||||||
|
<label>Email</label>
|
||||||
|
<input id="authEmail" placeholder="you@example.com" />
|
||||||
|
<label>Password</label>
|
||||||
|
<input id="authPassword" type="password" placeholder="password" />
|
||||||
|
<div class="row">
|
||||||
|
<button id="signUpBtn" class="alt">Sign Up</button>
|
||||||
|
<button id="signInBtn">Sign In</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="sessionBtn" class="alt">Check Session</button>
|
||||||
|
<button id="signOutBtn" class="danger">Sign Out</button>
|
||||||
|
</div>
|
||||||
|
<pre id="authState"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Device Bootstrap</h2>
|
<h2>Device Bootstrap</h2>
|
||||||
<label>Device Name</label>
|
<label>Device Name</label>
|
||||||
@@ -186,6 +205,7 @@
|
|||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const state = {
|
const state = {
|
||||||
|
session: null,
|
||||||
device: null,
|
device: null,
|
||||||
deviceToken: null,
|
deviceToken: null,
|
||||||
socket: null,
|
socket: null,
|
||||||
@@ -209,6 +229,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
|
$('authState').textContent = JSON.stringify({ session: state.session }, null, 2);
|
||||||
$('deviceState').textContent = JSON.stringify({ device: state.device, hasToken: Boolean(state.deviceToken) }, null, 2);
|
$('deviceState').textContent = JSON.stringify({ device: state.device, hasToken: Boolean(state.deviceToken) }, null, 2);
|
||||||
$('clientState').textContent = JSON.stringify({ lastStreamSessionId: state.lastStreamSessionId }, null, 2);
|
$('clientState').textContent = JSON.stringify({ lastStreamSessionId: state.lastStreamSessionId }, null, 2);
|
||||||
$('cameraState').textContent = JSON.stringify(
|
$('cameraState').textContent = JSON.stringify(
|
||||||
@@ -222,6 +243,64 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAuthPayload = () => ({
|
||||||
|
name: $('authName').value.trim() || undefined,
|
||||||
|
email: $('authEmail').value.trim(),
|
||||||
|
password: $('authPassword').value,
|
||||||
|
});
|
||||||
|
|
||||||
|
$('signUpBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const authPayload = getAuthPayload();
|
||||||
|
const payload = await authFetch('/api/auth/sign-up/email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(authPayload),
|
||||||
|
});
|
||||||
|
log('sign up', payload);
|
||||||
|
$('sessionBtn').click();
|
||||||
|
} catch (error) {
|
||||||
|
log('sign up failed', { error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('signInBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const authPayload = getAuthPayload();
|
||||||
|
const payload = await authFetch('/api/auth/sign-in/email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email: authPayload.email, password: authPayload.password }),
|
||||||
|
});
|
||||||
|
log('sign in', payload);
|
||||||
|
$('sessionBtn').click();
|
||||||
|
} catch (error) {
|
||||||
|
log('sign in failed', { error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('sessionBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const payload = await authFetch('/api/auth/get-session');
|
||||||
|
state.session = payload?.session ? payload : null;
|
||||||
|
render();
|
||||||
|
log('session check', payload);
|
||||||
|
} catch (error) {
|
||||||
|
state.session = null;
|
||||||
|
render();
|
||||||
|
log('session check failed', { error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('signOutBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const payload = await authFetch('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}) });
|
||||||
|
state.session = null;
|
||||||
|
render();
|
||||||
|
log('sign out', payload);
|
||||||
|
} catch (error) {
|
||||||
|
log('sign out failed', { error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const authFetch = async (url, options = {}) => {
|
const authFetch = async (url, options = {}) => {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -337,6 +416,7 @@
|
|||||||
|
|
||||||
$('registerBtn').addEventListener('click', async () => {
|
$('registerBtn').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!state.session) throw new Error('Authenticate first');
|
||||||
const role = $('role').value;
|
const role = $('role').value;
|
||||||
const name = $('deviceName').value.trim();
|
const name = $('deviceName').value.trim();
|
||||||
const payload = await authFetch('/devices/register', {
|
const payload = await authFetch('/devices/register', {
|
||||||
@@ -370,6 +450,24 @@
|
|||||||
log('loaded saved device', parsed);
|
log('loaded saved device', parsed);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const auditPanel = document.createElement('section');
|
||||||
|
auditPanel.className = 'panel';
|
||||||
|
auditPanel.style.marginTop = '16px';
|
||||||
|
auditPanel.innerHTML = `
|
||||||
|
<h2>Audit Logs</h2>
|
||||||
|
<button id="fetchAuditBtn" class="alt">Fetch My Device Audit Logs</button>
|
||||||
|
`;
|
||||||
|
document.querySelector('.page').appendChild(auditPanel);
|
||||||
|
|
||||||
|
$('fetchAuditBtn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const payload = await deviceFetch('/audit/device');
|
||||||
|
log('audit logs', payload);
|
||||||
|
} catch (error) {
|
||||||
|
log('audit fetch failed', { error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('loadSavedBtn').addEventListener('click', async () => {
|
$('loadSavedBtn').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
if (!state.device?.id) return;
|
if (!state.device?.id) return;
|
||||||
|
|||||||
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 { requireAuth } from '../middleware/auth';
|
||||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||||
import { sendRealtimeToDevice } from '../realtime/gateway';
|
import { sendRealtimeToDevice } from '../realtime/gateway';
|
||||||
|
import { writeAuditLog } from '../services/audit';
|
||||||
import { enqueuePushNotification } from '../services/push';
|
import { enqueuePushNotification } from '../services/push';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -126,6 +127,16 @@ router.post('/motion/start', requireDeviceAuth, async (req, res) => {
|
|||||||
event,
|
event,
|
||||||
notifiedClients: activeLinks.length,
|
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) => {
|
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 });
|
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) => {
|
router.get('/', requireAuth, async (req, res) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
|||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { recordings, streamSessions } from '../db/schema';
|
import { recordings, streamSessions } from '../db/schema';
|
||||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||||
|
import { writeAuditLog } from '../services/audit';
|
||||||
import { minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio';
|
import { minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -105,6 +106,16 @@ router.post('/:recordingId/finalize', requireDeviceAuth, async (req, res) => {
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
res.json({ message: 'Recording finalized', recording: updated });
|
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) => {
|
router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) => {
|
||||||
@@ -156,6 +167,16 @@ router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) =>
|
|||||||
downloadUrl,
|
downloadUrl,
|
||||||
expiresInSeconds: minioPresignedExpirySeconds,
|
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.
|
// 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 { mediaProvider } from '../media/service';
|
||||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||||
import { dispatchCommandById, sendRealtimeToDevice } from '../realtime/gateway';
|
import { dispatchCommandById, sendRealtimeToDevice } from '../realtime/gateway';
|
||||||
|
import { writeAuditLog } from '../services/audit';
|
||||||
import { enqueuePushNotification } from '../services/push';
|
import { enqueuePushNotification } from '../services/push';
|
||||||
import { createRecordingForStream } from './recordings';
|
import { createRecordingForStream } from './recordings';
|
||||||
|
|
||||||
@@ -192,6 +193,16 @@ router.post('/request', requireDeviceAuth, async (req, res) => {
|
|||||||
streamSession: session,
|
streamSession: session,
|
||||||
command: refreshedCommand ?? command,
|
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) => {
|
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 });
|
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) => {
|
router.get('/:streamSessionId/publish-credentials', requireDeviceAuth, async (req, res) => {
|
||||||
@@ -330,6 +351,16 @@ router.get('/:streamSessionId/publish-credentials', requireDeviceAuth, async (re
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json(credentials);
|
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) => {
|
router.get('/:streamSessionId/subscribe-credentials', requireDeviceAuth, async (req, res) => {
|
||||||
@@ -376,6 +407,16 @@ router.get('/:streamSessionId/subscribe-credentials', requireDeviceAuth, async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json(credentials);
|
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) => {
|
router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
|
||||||
|
|||||||
22
Backend/services/audit.ts
Normal file
22
Backend/services/audit.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { db } from '../db/client';
|
||||||
|
import { auditLogs } from '../db/schema';
|
||||||
|
|
||||||
|
export const writeAuditLog = async (entry: {
|
||||||
|
ownerUserId: string;
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId: string;
|
||||||
|
actorDeviceId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
ipAddress?: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
await db.insert(auditLogs).values({
|
||||||
|
ownerUserId: entry.ownerUserId,
|
||||||
|
actorDeviceId: entry.actorDeviceId,
|
||||||
|
action: entry.action,
|
||||||
|
targetType: entry.targetType,
|
||||||
|
targetId: entry.targetId,
|
||||||
|
metadata: entry.metadata ?? null,
|
||||||
|
ipAddress: entry.ipAddress,
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user