feat(S2-a): ingest durable app MQTT points securely

This commit is contained in:
Brent Perteet
2026-08-20 15:22:40 -05:00
parent daa3407b9d
commit 8bcc12ec96
14 changed files with 979 additions and 59 deletions

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AppLogIngestService } from './app-log-ingest.service';
import { JobsIngestService } from './jobs-ingest.service';
import { LogIngestService } from './log-ingest.service';
import { MqttClientService } from './mqtt-client.service';
@@ -15,6 +16,7 @@ export class IngestRouterService implements OnModuleInit {
private readonly pointsIngest: PointsIngestService,
private readonly jobsIngest: JobsIngestService,
private readonly logIngest: LogIngestService,
private readonly appLogIngest: AppLogIngestService,
) {}
onModuleInit() {
@@ -26,10 +28,32 @@ export class IngestRouterService implements OnModuleInit {
}
private async route(topic: string, payload: string) {
// Raw log captures every message on devices/#, matched or not (audit/debug trail)
await this.prisma.deviceEvent.create({ data: { topic, payload } });
const segments = topic.split('/');
const root = segments[0];
const [root, idSegment, ...rest] = topic.split('/');
// App point payloads contain personal/location data. Preserve routing evidence without
// creating an unmanaged second copy of the telemetry in device_events (S2 security H3).
const auditPayload =
root === 'ul' &&
segments[2] === 'app' &&
segments.slice(4).join('/') === 'log/points'
? JSON.stringify({
redacted: true,
bytes: Buffer.byteLength(payload, 'utf8'),
})
: payload;
await this.prisma.deviceEvent.create({
data: { topic, payload: auditPayload },
});
// App / device MQTT namespace: ul/{orgId}/{clientClass}/{clientId}/{subtopic...}
// (SRS §3.4.2). Sprint 2 handles the durable app-log path; other clientClasses and
// subtopics are represented but not yet ingested here.
if (root === 'ul') {
return this.routeUl(segments, payload);
}
const [, idSegment, ...rest] = segments;
const subtopic = rest.join('/');
if (root !== 'devices' || !idSegment || subtopic === 'jobs/ack') {
return; // not device traffic, or our own ack echoed back
@@ -42,13 +66,19 @@ export class IngestRouterService implements OnModuleInit {
return;
}
const device = await this.prisma.device.findUnique({ where: { mqttUsername: idSegment } });
const device = await this.prisma.device.findUnique({
where: { mqttUsername: idSegment },
});
if (!device) {
this.logger.warn(`Message from unregistered device username "${idSegment}" (raw-logged only)`);
this.logger.warn(
`Message from unregistered device username "${idSegment}" (raw-logged only)`,
);
return;
}
if (!device.isActive) {
this.logger.warn(`Message from deactivated device "${idSegment}" ignored`);
this.logger.warn(
`Message from deactivated device "${idSegment}" ignored`,
);
return;
}
@@ -69,4 +99,29 @@ export class IngestRouterService implements OnModuleInit {
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${idSegment}`);
}
}
// ul/{orgId}/{clientClass}/{clientId}/{subtopic...} — SRS §3.4.2 topic scheme.
// The namespace itself carries tenant (orgId) + publisher (clientId) identity; the
// broker ACL confines a publisher to its own namespace, and AppLogIngest re-checks
// tenant scope on the payload's job as a backstop. Only the durable app-log path is
// ingested this sprint.
private async routeUl(segments: string[], payload: string) {
const [, orgId, clientClass, clientId, ...rest] = segments;
const subtopic = rest.join('/');
if (!orgId || !clientClass || !clientId) {
this.logger.warn(`Malformed ul topic "${segments.join('/')}" — ignored`);
return;
}
// Our own cloud→publisher acks are echoed back to us on the same subscription; skip.
if (subtopic === 'ack') {
return;
}
if (clientClass === 'app' && subtopic === 'log/points') {
await this.appLogIngest.handle(orgId, clientId, payload);
return;
}
this.logger.debug(
`Unhandled ul path "${clientClass}/${subtopic}" (org ${orgId}, client ${clientId})`,
);
}
}