fix(api): derive secure sse subscriptions

This commit is contained in:
2026-03-30 14:20:18 +02:00
parent 27b0e38b93
commit 82466a4e34
7 changed files with 281 additions and 29 deletions
+13 -9
View File
@@ -1,7 +1,11 @@
import { Redis } from "ioredis";
import { PermissionKey, SSE_EVENT_TYPES, SystemRole, type SseEventType } from "@capakraken/shared";
export type SseAudience = string;
export type UserSseAudience = `user:${string}`;
export type RoleSseAudience = `role:${string}`;
export type PermissionSseAudience = `permission:${string}`;
export type ResourceSseAudience = `resource:${string}`;
export type SseAudience = UserSseAudience | RoleSseAudience | PermissionSseAudience | ResourceSseAudience;
export interface SseEvent {
type: SseEventType;
@@ -42,8 +46,8 @@ interface 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();
export function canonicalizeSseAudiences(audiences?: Iterable<SseAudience>): SseAudience[] {
return [...new Set(Array.from(audiences ?? [], (audience) => audience.trim() as SseAudience).filter(Boolean))].sort();
}
function getBufferKey(type: SseEventType, audience: readonly SseAudience[]): string {
@@ -91,7 +95,7 @@ function extractScopedResourceIds(payload: Record<string, unknown>): string[] {
}
function buildPlanningAudiences(payload: Record<string, unknown>): SseAudience[] {
return normalizeAudiences([
return canonicalizeSseAudiences([
permissionAudience(PermissionKey.MANAGE_ALLOCATIONS),
...extractScopedResourceIds(payload).map((resourceId) => resourceAudience(resourceId)),
]);
@@ -181,7 +185,7 @@ function setupSubscriber(): void {
type: parsed.type,
payload: parsed.payload,
timestamp: parsed.timestamp,
audience: normalizeAudiences(parsed.audience),
audience: canonicalizeSseAudiences(parsed.audience),
});
} catch {
// ignore parse errors
@@ -200,7 +204,7 @@ class EventBus {
subscribe(fn: Subscriber, options: SseSubscriptionOptions = {}): () => void {
const subscription: Subscription = {
fn,
audiences: new Set(normalizeAudiences(options.audiences)),
audiences: new Set(canonicalizeSseAudiences(options.audiences)),
includeUnscoped: options.includeUnscoped ?? true,
};
subscribers.add(subscription);
@@ -210,7 +214,7 @@ class EventBus {
publish(event: SseEvent): void {
const normalizedEvent: SseEvent = {
...event,
audience: normalizeAudiences(event.audience),
audience: canonicalizeSseAudiences(event.audience),
};
// Broadcast via Redis (all instances receive via subscriber.on("message"))
@@ -237,7 +241,7 @@ class EventBus {
type,
payload,
timestamp: new Date().toISOString(),
audience: normalizeAudiences(audience),
audience: canonicalizeSseAudiences(audience),
});
}
@@ -249,7 +253,7 @@ class EventBus {
// 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 audience = normalizeAudiences(event.audience);
const audience = canonicalizeSseAudiences(event.audience);
const key = getBufferKey(event.type, audience);
const existing = debounceBuffer.get(key);
+2
View File
@@ -0,0 +1,2 @@
export * from "./event-bus.js";
export * from "./subscription-policy.js";
@@ -0,0 +1,45 @@
import { resolvePermissions, type PermissionKey, type PermissionOverrides, SystemRole } from "@capakraken/shared";
import {
canonicalizeSseAudiences,
permissionAudience,
resourceAudience,
roleAudience,
type SseAudience,
type SseSubscriptionOptions,
userAudience,
} from "./event-bus.js";
export interface SseSubscriberIdentity {
userId: string;
systemRole: SystemRole;
permissionOverrides?: PermissionOverrides | null;
resourceId?: string | null;
}
export interface DerivedSseSubscription extends SseSubscriptionOptions {
audiences: SseAudience[];
permissions: Set<PermissionKey>;
includeUnscoped: false;
}
export function deriveUserSseSubscription(
identity: SseSubscriberIdentity,
roleDefaults?: Record<string, PermissionKey[]>,
): DerivedSseSubscription {
const permissions = resolvePermissions(
identity.systemRole,
identity.permissionOverrides ?? null,
roleDefaults,
);
return {
audiences: canonicalizeSseAudiences([
userAudience(identity.userId),
roleAudience(identity.systemRole),
...(identity.resourceId ? [resourceAudience(identity.resourceId)] : []),
...Array.from(permissions, (permission) => permissionAudience(permission)),
]),
permissions,
includeUnscoped: false,
};
}