feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
+142 -51
View File
@@ -1,20 +1,34 @@
import { Redis } from "ioredis";
import { SSE_EVENT_TYPES, type SseEventType } from "@capakraken/shared";
import { PermissionKey, SSE_EVENT_TYPES, SystemRole, type SseEventType } from "@capakraken/shared";
export type SseAudience = string;
export interface SseEvent {
type: SseEventType;
payload: Record<string, unknown>;
timestamp: string;
audience: SseAudience[];
}
type Subscriber = (event: SseEvent) => void;
interface Subscription {
fn: Subscriber;
audiences: Set<SseAudience>;
includeUnscoped: boolean;
}
export interface SseSubscriptionOptions {
audiences?: Iterable<SseAudience>;
includeUnscoped?: boolean;
}
// Module-level subscriber registry (shared between EventBus and publishLocal)
const subscribers = new Set<Subscriber>();
const subscribers = new Set<Subscription>();
// ---------------------------------------------------------------------------
// Debounce buffer: aggregates rapid events of the same type within a 50ms
// window and delivers a single event per type to subscribers.
// Debounce buffer: aggregates rapid events of the same type and audience within
// a 50ms window and delivers a single event per scope to subscribers.
// ---------------------------------------------------------------------------
const DEBOUNCE_MS = 50;
@@ -23,48 +37,76 @@ interface BufferEntry {
payloads: Record<string, unknown>[];
timer: ReturnType<typeof setTimeout>;
firstTimestamp: string;
audience: SseAudience[];
}
const debounceBuffer = new Map<SseEventType, BufferEntry>();
const debounceBuffer = new Map<string, BufferEntry>();
function normalizeAudiences(audiences?: Iterable<SseAudience>): SseAudience[] {
return [...new Set(Array.from(audiences ?? [], (audience) => audience.trim()).filter(Boolean))].sort();
}
function getBufferKey(type: SseEventType, audience: readonly SseAudience[]): string {
return `${type}::${audience.length > 0 ? audience.join("|") : "__unscoped__"}`;
}
function matchesSubscription(event: SseEvent, subscription: Subscription): boolean {
if (event.audience.length === 0) {
return subscription.includeUnscoped;
}
return event.audience.some((audience) => subscription.audiences.has(audience));
}
function deliverEvent(event: SseEvent): void {
for (const subscription of subscribers) {
if (matchesSubscription(event, subscription)) {
subscription.fn(event);
}
}
}
export const userAudience = (userId: string): SseAudience => `user:${userId}`;
export const roleAudience = (role: string): SseAudience => `role:${role}`;
export const permissionAudience = (permission: string): SseAudience => `permission:${permission}`;
/** Flush a single event type from the buffer and deliver to subscribers. */
function flushEventType(type: SseEventType): void {
const entry = debounceBuffer.get(type);
function flushEventType(type: SseEventType, audience: readonly SseAudience[]): void {
const key = getBufferKey(type, audience);
const entry = debounceBuffer.get(key);
if (!entry) return;
debounceBuffer.delete(type);
debounceBuffer.delete(key);
const event: SseEvent =
entry.payloads.length === 1
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp, audience: entry.audience }
: {
type,
payload: { _batch: entry.payloads },
timestamp: entry.firstTimestamp,
audience: entry.audience,
};
for (const fn of subscribers) {
fn(event);
}
deliverEvent(event);
}
/** Flush all pending debounce timers immediately (for cleanup / tests). */
export function flushPendingEvents(): void {
for (const [type, entry] of debounceBuffer) {
for (const [key, entry] of debounceBuffer) {
clearTimeout(entry.timer);
debounceBuffer.delete(type);
debounceBuffer.delete(key);
const [type] = key.split("::") as [SseEventType];
const event: SseEvent =
entry.payloads.length === 1
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp, audience: entry.audience }
: {
type,
payload: { _batch: entry.payloads },
timestamp: entry.firstTimestamp,
audience: entry.audience,
};
for (const fn of subscribers) {
fn(event);
}
deliverEvent(event);
}
}
@@ -101,9 +143,21 @@ function setupSubscriber(): void {
});
subscriber.on("message", (_channel: string, message: string) => {
try {
const parsed = JSON.parse(message) as { type: SseEventType; payload: Record<string, unknown>; timestamp: string };
publishLocal({ type: parsed.type, payload: parsed.payload, timestamp: parsed.timestamp });
} catch { /* ignore parse errors */ }
const parsed = JSON.parse(message) as {
type: SseEventType;
payload: Record<string, unknown>;
timestamp: string;
audience?: SseAudience[];
};
publishLocal({
type: parsed.type,
payload: parsed.payload,
timestamp: parsed.timestamp,
audience: normalizeAudiences(parsed.audience),
});
} catch {
// ignore parse errors
}
});
} catch (e) {
console.warn("[Redis setupSubscriber] Redis unavailable, SSE will be local-only:", e);
@@ -115,28 +169,47 @@ function setupSubscriber(): void {
* Gracefully degrades to in-memory delivery when Redis is unavailable.
*/
class EventBus {
subscribe(fn: Subscriber): () => void {
subscribers.add(fn);
return () => subscribers.delete(fn);
subscribe(fn: Subscriber, options: SseSubscriptionOptions = {}): () => void {
const subscription: Subscription = {
fn,
audiences: new Set(normalizeAudiences(options.audiences)),
includeUnscoped: options.includeUnscoped ?? true,
};
subscribers.add(subscription);
return () => subscribers.delete(subscription);
}
publish(event: SseEvent): void {
const normalizedEvent: SseEvent = {
...event,
audience: normalizeAudiences(event.audience),
};
// Broadcast via Redis (all instances receive via subscriber.on("message"))
try {
const pub = getPublisher();
void pub.publish(CHANNEL, JSON.stringify({ type: event.type, payload: event.payload, timestamp: event.timestamp }));
void pub.publish(
CHANNEL,
JSON.stringify({
type: normalizedEvent.type,
payload: normalizedEvent.payload,
timestamp: normalizedEvent.timestamp,
audience: normalizedEvent.audience,
}),
);
} catch (e) {
console.warn("[Redis emit] fallback to local-only:", e);
// Deliver locally when Redis is unavailable
publishLocal(event);
publishLocal(normalizedEvent);
}
}
emit(type: SseEventType, payload: Record<string, unknown>): void {
emit(type: SseEventType, payload: Record<string, unknown>, audience: Iterable<SseAudience> = []): void {
this.publish({
type,
payload,
timestamp: new Date().toISOString(),
audience: normalizeAudiences(audience),
});
}
@@ -145,23 +218,26 @@ class EventBus {
}
}
// Local delivery with debounce: buffer events of the same type within a 50ms
// window and then deliver a single (possibly aggregated) event to subscribers.
// Local delivery with debounce: buffer events of the same type and audience
// within a 50ms window and then deliver a single (possibly aggregated) event.
function publishLocal(event: SseEvent): void {
const existing = debounceBuffer.get(event.type);
const audience = normalizeAudiences(event.audience);
const key = getBufferKey(event.type, audience);
const existing = debounceBuffer.get(key);
if (existing) {
// Another event of the same type is already buffered — append payload and
// reset the timer so the window starts fresh from the latest arrival.
existing.payloads.push(event.payload);
clearTimeout(existing.timer);
existing.timer = setTimeout(() => flushEventType(event.type), DEBOUNCE_MS);
existing.timer = setTimeout(() => flushEventType(event.type, audience), DEBOUNCE_MS);
} else {
// First event of this type — start a new debounce window.
debounceBuffer.set(event.type, {
// First event of this type and audience — start a new debounce window.
debounceBuffer.set(key, {
payloads: [event.payload],
timer: setTimeout(() => flushEventType(event.type), DEBOUNCE_MS),
timer: setTimeout(() => flushEventType(event.type, audience), DEBOUNCE_MS),
firstTimestamp: event.timestamp,
audience,
});
}
}
@@ -174,58 +250,73 @@ setupSubscriber();
// Helper emitters
export const emitAllocationCreated = (allocation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation);
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitAllocationUpdated = (allocation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation);
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitAllocationDeleted = (allocationId: string, projectId: string) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_DELETED, { allocationId, projectId });
eventBus.emit(
SSE_EVENT_TYPES.ALLOCATION_DELETED,
{ allocationId, projectId },
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
);
export const emitProjectShifted = (project: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project);
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitBudgetWarning = (projectId: string, payload: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.BUDGET_WARNING, { projectId, ...payload });
eventBus.emit(
SSE_EVENT_TYPES.BUDGET_WARNING,
{ projectId, ...payload },
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
);
export const emitVacationCreated = (vacation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation);
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitVacationUpdated = (vacation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation);
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
export const emitVacationDeleted = (vacationId: string, resourceId: string) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_DELETED, { vacationId, resourceId });
eventBus.emit(
SSE_EVENT_TYPES.VACATION_DELETED,
{ vacationId, resourceId },
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
);
export const emitRoleCreated = (role: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_CREATED, role);
eventBus.emit(SSE_EVENT_TYPES.ROLE_CREATED, role, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
export const emitRoleUpdated = (role: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, role);
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, role, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
export const emitRoleDeleted = (roleId: string) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_DELETED, { roleId });
eventBus.emit(SSE_EVENT_TYPES.ROLE_DELETED, { roleId }, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
export function emitNotificationCreated(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId }, [userAudience(userId)]);
}
export function emitTaskAssigned(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.TASK_ASSIGNED, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.TASK_ASSIGNED, { userId, notificationId }, [userAudience(userId)]);
}
export function emitTaskCompleted(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.TASK_COMPLETED, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.TASK_COMPLETED, { userId, notificationId }, [userAudience(userId)]);
}
export function emitTaskStatusChanged(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.TASK_STATUS_CHANGED, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.TASK_STATUS_CHANGED, { userId, notificationId }, [userAudience(userId)]);
}
export function emitReminderDue(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.REMINDER_DUE, { userId, notificationId });
eventBus.emit(SSE_EVENT_TYPES.REMINDER_DUE, { userId, notificationId }, [userAudience(userId)]);
}
export function emitBroadcastSent(broadcastId: string, recipientCount: number): void {
eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount });
eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount }, [
roleAudience(SystemRole.ADMIN),
roleAudience(SystemRole.MANAGER),
]);
}