refactor(sse): narrow canonical audience scopes
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
- SSE subscriptions are derived on the server from the authenticated database user.
|
- SSE subscriptions are derived on the server from the authenticated database user.
|
||||||
- Clients do not provide arbitrary audience lists.
|
- Clients do not provide arbitrary audience lists.
|
||||||
- Every scoped event declares its audience explicitly.
|
- Every scoped event declares its audience explicitly.
|
||||||
|
- Invalid audience strings fail fast in the event bus.
|
||||||
- Unscoped delivery is disabled for authenticated timeline subscriptions.
|
- Unscoped delivery is disabled for authenticated timeline subscriptions.
|
||||||
|
|
||||||
## Audience Model
|
## Audience Model
|
||||||
@@ -12,10 +13,11 @@
|
|||||||
Supported canonical audiences:
|
Supported canonical audiences:
|
||||||
|
|
||||||
- `user:<userId>` for personal notifications and tasks
|
- `user:<userId>` for personal notifications and tasks
|
||||||
- `role:<systemRole>` for role-wide operational events
|
|
||||||
- `permission:<permissionKey>` for explicit capability audiences
|
- `permission:<permissionKey>` for explicit capability audiences
|
||||||
- `resource:<resourceId>` for self-service or affected-resource updates
|
- `resource:<resourceId>` for self-service or affected-resource updates
|
||||||
|
|
||||||
|
Any other prefix is invalid and must be rejected instead of being accepted silently.
|
||||||
|
|
||||||
## Subscription Flow
|
## Subscription Flow
|
||||||
|
|
||||||
1. The web SSE route authenticates the session and loads the database user.
|
1. The web SSE route authenticates the session and loads the database user.
|
||||||
@@ -28,7 +30,7 @@ Supported canonical audiences:
|
|||||||
|
|
||||||
- Personal events such as notifications must target `user:<userId>`.
|
- Personal events such as notifications must target `user:<userId>`.
|
||||||
- Planning events may target both `permission:manageAllocations` and affected `resource:<resourceId>` audiences.
|
- Planning events may target both `permission:manageAllocations` and affected `resource:<resourceId>` audiences.
|
||||||
- Broad operational events must use the smallest real audience that matches the use case.
|
- Broad operational events must use the smallest real audience that matches the use case, typically `permission:*` rather than role fan-out.
|
||||||
|
|
||||||
## Contract Tests
|
## Contract Tests
|
||||||
|
|
||||||
|
|||||||
@@ -265,4 +265,24 @@ describe("event-bus debounce", () => {
|
|||||||
unsubscribeManager();
|
unsubscribeManager();
|
||||||
unsubscribeResource();
|
unsubscribeResource();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects invalid subscription audiences", () => {
|
||||||
|
expect(() =>
|
||||||
|
eventBus.subscribe(
|
||||||
|
() => undefined,
|
||||||
|
{
|
||||||
|
audiences: ["chapter:capex" as never],
|
||||||
|
includeUnscoped: false,
|
||||||
|
},
|
||||||
|
)).toThrowError("Invalid SSE audience: chapter:capex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid emitted audiences", () => {
|
||||||
|
expect(() =>
|
||||||
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
|
||||||
|
{ notificationId: "n1" },
|
||||||
|
["user:" as never],
|
||||||
|
)).toThrowError("Invalid SSE audience: user:");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
eventBus,
|
eventBus,
|
||||||
permissionAudience,
|
permissionAudience,
|
||||||
resourceAudience,
|
resourceAudience,
|
||||||
roleAudience,
|
|
||||||
type SseEvent,
|
type SseEvent,
|
||||||
userAudience,
|
userAudience,
|
||||||
} from "../sse/event-bus.js";
|
} from "../sse/event-bus.js";
|
||||||
@@ -36,7 +35,7 @@ describe("sse subscription policy", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives canonical user, role, resource, and permission audiences server-side", () => {
|
it("derives canonical user, resource, and permission audiences server-side", () => {
|
||||||
const subscription = deriveUserSseSubscription({
|
const subscription = deriveUserSseSubscription({
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
systemRole: SystemRole.USER,
|
systemRole: SystemRole.USER,
|
||||||
@@ -55,7 +54,6 @@ describe("sse subscription policy", () => {
|
|||||||
permissionAudience(PermissionKey.MANAGE_ALLOCATIONS),
|
permissionAudience(PermissionKey.MANAGE_ALLOCATIONS),
|
||||||
permissionAudience(PermissionKey.VIEW_PLANNING),
|
permissionAudience(PermissionKey.VIEW_PLANNING),
|
||||||
resourceAudience("res_1"),
|
resourceAudience("res_1"),
|
||||||
roleAudience(SystemRole.USER),
|
|
||||||
userAudience("user_1"),
|
userAudience("user_1"),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { PermissionKey, SSE_EVENT_TYPES, SystemRole, type SseEventType } from "@capakraken/shared";
|
import { PermissionKey, SSE_EVENT_TYPES, type SseEventType } from "@capakraken/shared";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
|
||||||
export type UserSseAudience = `user:${string}`;
|
export type UserSseAudience = `user:${string}`;
|
||||||
export type RoleSseAudience = `role:${string}`;
|
|
||||||
export type PermissionSseAudience = `permission:${string}`;
|
export type PermissionSseAudience = `permission:${string}`;
|
||||||
export type ResourceSseAudience = `resource:${string}`;
|
export type ResourceSseAudience = `resource:${string}`;
|
||||||
export type SseAudience = UserSseAudience | RoleSseAudience | PermissionSseAudience | ResourceSseAudience;
|
export type SseAudience = UserSseAudience | PermissionSseAudience | ResourceSseAudience;
|
||||||
|
const SSE_AUDIENCE_PREFIXES = ["user", "permission", "resource"] as const;
|
||||||
|
type SseAudiencePrefix = (typeof SSE_AUDIENCE_PREFIXES)[number];
|
||||||
|
|
||||||
export interface SseEvent {
|
export interface SseEvent {
|
||||||
type: SseEventType;
|
type: SseEventType;
|
||||||
@@ -46,8 +48,32 @@ interface BufferEntry {
|
|||||||
|
|
||||||
const debounceBuffer = new Map<string, BufferEntry>();
|
const debounceBuffer = new Map<string, BufferEntry>();
|
||||||
|
|
||||||
|
function isSseAudiencePrefix(value: string): value is SseAudiencePrefix {
|
||||||
|
return SSE_AUDIENCE_PREFIXES.includes(value as SseAudiencePrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidSseAudience(audience: string): SseAudience {
|
||||||
|
const normalized = audience.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("Invalid SSE audience: empty value");
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = normalized.indexOf(":");
|
||||||
|
if (separatorIndex <= 0 || separatorIndex === normalized.length - 1) {
|
||||||
|
throw new Error(`Invalid SSE audience: ${normalized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = normalized.slice(0, separatorIndex);
|
||||||
|
const scope = normalized.slice(separatorIndex + 1).trim();
|
||||||
|
if (!isSseAudiencePrefix(prefix) || !scope) {
|
||||||
|
throw new Error(`Invalid SSE audience: ${normalized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}:${scope}` as SseAudience;
|
||||||
|
}
|
||||||
|
|
||||||
export function canonicalizeSseAudiences(audiences?: Iterable<SseAudience>): SseAudience[] {
|
export function canonicalizeSseAudiences(audiences?: Iterable<SseAudience>): SseAudience[] {
|
||||||
return [...new Set(Array.from(audiences ?? [], (audience) => audience.trim() as SseAudience).filter(Boolean))].sort();
|
return [...new Set(Array.from(audiences ?? [], (audience) => assertValidSseAudience(audience)).filter(Boolean))].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBufferKey(type: SseEventType, audience: readonly SseAudience[]): string {
|
function getBufferKey(type: SseEventType, audience: readonly SseAudience[]): string {
|
||||||
@@ -70,7 +96,6 @@ function deliverEvent(event: SseEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const userAudience = (userId: string): SseAudience => `user:${userId}`;
|
export const userAudience = (userId: string): SseAudience => `user:${userId}`;
|
||||||
export const roleAudience = (role: string): SseAudience => `role:${role}`;
|
|
||||||
export const permissionAudience = (permission: string): SseAudience => `permission:${permission}`;
|
export const permissionAudience = (permission: string): SseAudience => `permission:${permission}`;
|
||||||
export const resourceAudience = (resourceId: string): SseAudience => `resource:${resourceId}`;
|
export const resourceAudience = (resourceId: string): SseAudience => `resource:${resourceId}`;
|
||||||
|
|
||||||
@@ -160,7 +185,9 @@ let subscriber: Redis | null = null;
|
|||||||
function getPublisher(): Redis {
|
function getPublisher(): Redis {
|
||||||
if (!publisher) {
|
if (!publisher) {
|
||||||
publisher = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false });
|
publisher = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false });
|
||||||
publisher.on("error", (e: unknown) => console.error("[Redis publisher]", e));
|
publisher.on("error", (e: unknown) => {
|
||||||
|
logger.warn({ err: e, redisUrl: REDIS_URL, channel: CHANNEL }, "Redis publisher emitted an error");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return publisher;
|
return publisher;
|
||||||
}
|
}
|
||||||
@@ -169,9 +196,11 @@ function setupSubscriber(): void {
|
|||||||
if (subscriber) return;
|
if (subscriber) return;
|
||||||
try {
|
try {
|
||||||
subscriber = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false });
|
subscriber = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false });
|
||||||
subscriber.on("error", (e: unknown) => console.error("[Redis subscriber]", e));
|
subscriber.on("error", (e: unknown) => {
|
||||||
|
logger.warn({ err: e, redisUrl: REDIS_URL, channel: CHANNEL }, "Redis subscriber emitted an error");
|
||||||
|
});
|
||||||
void subscriber.subscribe(CHANNEL).catch((err: unknown) => {
|
void subscriber.subscribe(CHANNEL).catch((err: unknown) => {
|
||||||
console.error("[Redis subscribe]", err);
|
logger.warn({ err, redisUrl: REDIS_URL, channel: CHANNEL }, "Redis subscribe failed for SSE event bus");
|
||||||
});
|
});
|
||||||
subscriber.on("message", (_channel: string, message: string) => {
|
subscriber.on("message", (_channel: string, message: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -192,7 +221,7 @@ function setupSubscriber(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Redis setupSubscriber] Redis unavailable, SSE will be local-only:", e);
|
logger.warn({ err: e, redisUrl: REDIS_URL, channel: CHANNEL }, "Redis unavailable, SSE event bus will run in local-only mode");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +259,7 @@ class EventBus {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Redis emit] fallback to local-only:", e);
|
logger.warn({ err: e, redisUrl: REDIS_URL, channel: CHANNEL }, "Redis publish failed, falling back to local-only SSE delivery");
|
||||||
// Deliver locally when Redis is unavailable
|
// Deliver locally when Redis is unavailable
|
||||||
publishLocal(normalizedEvent);
|
publishLocal(normalizedEvent);
|
||||||
}
|
}
|
||||||
@@ -345,10 +374,3 @@ export function emitTaskStatusChanged(userId: string, notificationId: string): v
|
|||||||
export function emitReminderDue(userId: string, notificationId: string): void {
|
export function emitReminderDue(userId: string, notificationId: string): void {
|
||||||
eventBus.emit(SSE_EVENT_TYPES.REMINDER_DUE, { userId, notificationId }, [userAudience(userId)]);
|
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 }, [
|
|
||||||
roleAudience(SystemRole.ADMIN),
|
|
||||||
roleAudience(SystemRole.MANAGER),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
canonicalizeSseAudiences,
|
canonicalizeSseAudiences,
|
||||||
permissionAudience,
|
permissionAudience,
|
||||||
resourceAudience,
|
resourceAudience,
|
||||||
roleAudience,
|
|
||||||
type SseAudience,
|
type SseAudience,
|
||||||
type SseSubscriptionOptions,
|
type SseSubscriptionOptions,
|
||||||
userAudience,
|
userAudience,
|
||||||
@@ -35,7 +34,6 @@ export function deriveUserSseSubscription(
|
|||||||
return {
|
return {
|
||||||
audiences: canonicalizeSseAudiences([
|
audiences: canonicalizeSseAudiences([
|
||||||
userAudience(identity.userId),
|
userAudience(identity.userId),
|
||||||
roleAudience(identity.systemRole),
|
|
||||||
...(identity.resourceId ? [resourceAudience(identity.resourceId)] : []),
|
...(identity.resourceId ? [resourceAudience(identity.resourceId)] : []),
|
||||||
...Array.from(permissions, (permission) => permissionAudience(permission)),
|
...Array.from(permissions, (permission) => permissionAudience(permission)),
|
||||||
]),
|
]),
|
||||||
|
|||||||
Reference in New Issue
Block a user