Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
35
apps/api/src/audit/audit.controller.ts
Normal file
35
apps/api/src/audit/audit.controller.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { BadRequestException, Controller, Get, Inject, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { isUuid, parseLimit, requireSession } from "../http/validation.js";
|
||||
import { AuditQueryService, type AuditPage } from "./audit.query.js";
|
||||
|
||||
@Controller("audit")
|
||||
@UseGuards(RbacGuard)
|
||||
export class AuditController {
|
||||
constructor(@Inject(AuditQueryService) private readonly audit: AuditQueryService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("audit.view")
|
||||
async list(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Query("action") action: string | undefined,
|
||||
@Query("actor") actor: string | undefined,
|
||||
@Query("before") before: string | undefined,
|
||||
@Query("limit") limit: string | undefined,
|
||||
): Promise<AuditPage> {
|
||||
const session = requireSession(request);
|
||||
if (actor !== undefined && actor !== "" && actor !== "system" && !isUuid(actor)) {
|
||||
throw new BadRequestException("actor must be a user UUID or \"system\"");
|
||||
}
|
||||
if (before !== undefined && before !== "" && !/^\d{1,18}$/.test(before)) {
|
||||
throw new BadRequestException("before must be a sequence cursor");
|
||||
}
|
||||
return this.audit.page(session.workspaceId, {
|
||||
...(action === undefined || action === "" ? {} : { action }),
|
||||
...(actor === undefined || actor === "" ? {} : { actor }),
|
||||
...(before === undefined || before === "" ? {} : { before }),
|
||||
limit: parseLimit(limit, 50, 200),
|
||||
});
|
||||
}
|
||||
}
|
||||
113
apps/api/src/audit/audit.query.ts
Normal file
113
apps/api/src/audit/audit.query.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { withTenant } from "../db/client.js";
|
||||
|
||||
export interface AuditEntryDto {
|
||||
id: string;
|
||||
seq: string;
|
||||
at: string;
|
||||
actorUserId: string | null;
|
||||
actorEmail: string | null;
|
||||
actorRole: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
diff: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AuditActorFacet {
|
||||
/** null = system / service actor. */
|
||||
id: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface AuditPage {
|
||||
entries: AuditEntryDto[];
|
||||
/** Pass back as `before` to fetch the next (older) page; null when exhausted. */
|
||||
nextCursor: string | null;
|
||||
facets: { actions: string[]; actors: AuditActorFacet[] };
|
||||
}
|
||||
|
||||
export interface AuditFilter {
|
||||
action?: string;
|
||||
/** User UUID, or "system" for entries without a human actor. */
|
||||
actor?: string;
|
||||
before?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
seq: string;
|
||||
at: Date;
|
||||
actor_user_id: string | null;
|
||||
actor_email: string | null;
|
||||
actor_role: string | null;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
diff: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Read side of the tenant audit chain. RLS (tenant_audit policy) scopes rows to the session's workspace. */
|
||||
@Injectable()
|
||||
export class AuditQueryService {
|
||||
async page(workspaceId: string, filter: AuditFilter): Promise<AuditPage> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const actorIsSystem = filter.actor === "system";
|
||||
const rows = await client.query(
|
||||
`SELECT a.id, a.seq::text AS seq, a.at, a.actor_user_id, u.email AS actor_email, a.actor_role,
|
||||
a.action, a.target_type, a.target_id, a.diff
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_user_id
|
||||
WHERE a.workspace_id = $1
|
||||
AND ($2::text IS NULL OR a.action = $2)
|
||||
AND ($3::uuid IS NULL OR a.actor_user_id = $3)
|
||||
AND (NOT $4::boolean OR a.actor_user_id IS NULL)
|
||||
AND ($5::bigint IS NULL OR a.seq < $5)
|
||||
ORDER BY a.seq DESC
|
||||
LIMIT $6`,
|
||||
[
|
||||
workspaceId,
|
||||
filter.action ?? null,
|
||||
actorIsSystem ? null : (filter.actor ?? null),
|
||||
actorIsSystem,
|
||||
filter.before ?? null,
|
||||
filter.limit + 1,
|
||||
],
|
||||
);
|
||||
const all = rows.rows as AuditRow[];
|
||||
const hasMore = all.length > filter.limit;
|
||||
const entries = all.slice(0, filter.limit).map((r) => ({
|
||||
id: r.id,
|
||||
seq: r.seq,
|
||||
at: new Date(r.at).toISOString(),
|
||||
actorUserId: r.actor_user_id,
|
||||
actorEmail: r.actor_email,
|
||||
actorRole: r.actor_role,
|
||||
action: r.action,
|
||||
targetType: r.target_type,
|
||||
targetId: r.target_id,
|
||||
diff: r.diff,
|
||||
}));
|
||||
|
||||
const actions = await client.query(
|
||||
"SELECT DISTINCT action FROM audit_log WHERE workspace_id = $1 ORDER BY action",
|
||||
[workspaceId],
|
||||
);
|
||||
const actors = await client.query(
|
||||
`SELECT DISTINCT a.actor_user_id AS id, u.email
|
||||
FROM audit_log a LEFT JOIN users u ON u.id = a.actor_user_id
|
||||
WHERE a.workspace_id = $1 ORDER BY u.email NULLS LAST`,
|
||||
[workspaceId],
|
||||
);
|
||||
return {
|
||||
entries,
|
||||
nextCursor: hasMore ? (entries[entries.length - 1]?.seq ?? null) : null,
|
||||
facets: {
|
||||
actions: (actions.rows as Array<{ action: string }>).map((r) => r.action),
|
||||
actors: actors.rows as AuditActorFacet[],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
13
apps/api/src/audit/audit.scrub.test.ts
Normal file
13
apps/api/src/audit/audit.scrub.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { scrub } from "./audit.service.js";
|
||||
|
||||
describe("audit diff scrubbing", () => {
|
||||
it("redacts secret-shaped keys (spec: secrets never enter audit payloads)", () => {
|
||||
const clean = scrub({ password: "hunter2", stream_key: "sk-1", note: "hello", count: 3 });
|
||||
expect(clean).toEqual({ password: "[REDACTED]", stream_key: "[REDACTED]", note: "hello", count: 3 });
|
||||
});
|
||||
|
||||
it("passes null through", () => {
|
||||
expect(scrub(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
38
apps/api/src/audit/audit.service.ts
Normal file
38
apps/api/src/audit/audit.service.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { withTenant } from "../db/client.js";
|
||||
|
||||
/**
|
||||
* Audit service: hash-chained, secrets never enter payloads.
|
||||
* Branch selection (findings #1/#6): tenant context present => tenant chain
|
||||
* (audit_append), even for service roles; no tenant context => system chain.
|
||||
*/
|
||||
|
||||
const SECRET_KEYS = /(password|secret|token|key|credential|stream_key|refresh)/i;
|
||||
|
||||
export function scrub(diff: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (diff === null) return null;
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(diff)) {
|
||||
clean[k] = SECRET_KEYS.test(k) ? "[REDACTED]" : v;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
actorUserId: string | null;
|
||||
actorRole: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
diff?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function appendTenantAudit(workspaceId: string, event: AuditEvent): Promise<string> {
|
||||
const res = await withTenant(workspaceId, async (client) => {
|
||||
const result = await client.query(
|
||||
"SELECT audit_append($1, $2, $3, $4, $5, $6, $7) AS id",
|
||||
[workspaceId, event.actorUserId, event.actorRole, event.action, event.targetType, event.targetId, scrub(event.diff ?? null)],
|
||||
);
|
||||
return result.rows[0]?.id as string;
|
||||
});
|
||||
return res;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue