security: RBAC cache cross-instance invalidation + force re-login on role/perm change (#57)
- shrink roleDefaults cache TTL from 60s to 10s (safety-net staleness bound) - publish/subscribe on capakraken:rbac-invalidate so peer instances drop their local role-defaults cache on mutation (ioredis pub/sub; lazy init so idle test files don't open connections) - after updateUserRole/setUserPermissions/resetUserPermissions: delete all ActiveSession rows for that user so the next request re-auths via tRPC's jti check, and invalidate the role-defaults cache - tests: peer-instance invalidation via FakeRedis pub/sub fan-out; mutation side-effects assert session deletion + cache invalidation on each path Without this, demoted admins kept their JWT valid until expiry and peer instances kept serving stale role defaults for up to the TTL window. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Ticket #57 — verify that:
|
||||
*
|
||||
* 1. Publishing on RBAC_INVALIDATE_CHANNEL from node A causes node B to
|
||||
* drop its local `_roleDefaultsCache`, so its next `loadRoleDefaults()`
|
||||
* call re-reads from the DB (acceptance criterion:
|
||||
* "2nd node sees update within 1 s" — we verify the mechanism, not the
|
||||
* Redis latency).
|
||||
*
|
||||
* 2. `invalidateRoleDefaultsCache()` on the current node publishes on the
|
||||
* same channel so peer instances receive the event.
|
||||
*
|
||||
* Strategy: stub `ioredis` with an EventEmitter-based fake before loading
|
||||
* trpc.ts. The fake captures `publish()` calls and lets the test emit
|
||||
* synthetic "message" events.
|
||||
*/
|
||||
|
||||
// Fake Redis with two separate instances so the test mirrors the multi-node
|
||||
// shape: one as subscriber, one as publisher. Both share the same module-
|
||||
// level event router keyed by channel.
|
||||
const channelSubscribers = new Map<string, Set<FakeRedis>>();
|
||||
const publishCalls: Array<{ channel: string; message: string }> = [];
|
||||
|
||||
class FakeRedis extends EventEmitter {
|
||||
constructor(_url: string, _opts: unknown) {
|
||||
super();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async subscribe(channel: string): Promise<number> {
|
||||
let set = channelSubscribers.get(channel);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
channelSubscribers.set(channel, set);
|
||||
}
|
||||
set.add(this);
|
||||
return set.size;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async publish(channel: string, message: string): Promise<number> {
|
||||
publishCalls.push({ channel, message });
|
||||
const subs = channelSubscribers.get(channel);
|
||||
if (!subs) return 0;
|
||||
// Fan out synchronously so the subscriber handler runs before the test
|
||||
// assertion reads the cache — matches real ioredis "message" semantics
|
||||
// from the subscriber's point of view.
|
||||
for (const sub of subs) sub.emit("message", channel, message);
|
||||
return subs.size;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("ioredis", () => ({ Redis: FakeRedis, default: FakeRedis }));
|
||||
vi.mock("../lib/logger.js", () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
// Prisma client mock — loadRoleDefaults pulls from systemRoleConfig.findMany.
|
||||
const findManyCalls: number[] = [];
|
||||
vi.mock("@capakraken/db", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("@capakraken/db");
|
||||
return {
|
||||
...actual,
|
||||
prisma: {
|
||||
systemRoleConfig: {
|
||||
findMany: vi.fn().mockImplementation(async () => {
|
||||
findManyCalls.push(Date.now());
|
||||
return [{ role: "ADMIN", defaultPermissions: ["MANAGE_USERS"] }];
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// REDIS_URL is needed so trpc.ts decides to instantiate the fake Redis.
|
||||
// `trpc.ts` now reads it lazily on first RBAC call, so setting it in
|
||||
// beforeAll is enough; we always restore in afterAll to avoid leaking into
|
||||
// other test files in the same worker.
|
||||
const originalRedisUrl = process.env["REDIS_URL"];
|
||||
|
||||
describe("RBAC cache Redis pub/sub (#57)", () => {
|
||||
beforeAll(() => {
|
||||
process.env["REDIS_URL"] = "redis://fake:6379";
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalRedisUrl === undefined) delete process.env["REDIS_URL"];
|
||||
else process.env["REDIS_URL"] = originalRedisUrl;
|
||||
});
|
||||
beforeEach(() => {
|
||||
findManyCalls.length = 0;
|
||||
});
|
||||
|
||||
it("peer-instance invalidation: receiving a message clears the local cache", async () => {
|
||||
const { loadRoleDefaults } = await import("../trpc.js");
|
||||
|
||||
// Warm the cache.
|
||||
await loadRoleDefaults();
|
||||
const hitsAfterWarm = findManyCalls.length;
|
||||
expect(hitsAfterWarm).toBe(1);
|
||||
|
||||
// Second call within TTL should be cached — no additional findMany.
|
||||
await loadRoleDefaults();
|
||||
expect(findManyCalls.length).toBe(hitsAfterWarm);
|
||||
|
||||
// Simulate a peer instance publishing an invalidation: grab any
|
||||
// subscriber on the channel and fire the event as if Redis delivered it.
|
||||
const subs = channelSubscribers.get("capakraken:rbac-invalidate");
|
||||
expect(subs).toBeDefined();
|
||||
expect(subs!.size).toBeGreaterThanOrEqual(1);
|
||||
for (const sub of subs!) sub.emit("message", "capakraken:rbac-invalidate", "1");
|
||||
|
||||
// Next load must hit the DB again.
|
||||
await loadRoleDefaults();
|
||||
expect(findManyCalls.length).toBe(hitsAfterWarm + 1);
|
||||
});
|
||||
|
||||
it("local invalidation publishes on the RBAC channel", async () => {
|
||||
const { invalidateRoleDefaultsCache } = await import("../trpc.js");
|
||||
const countBefore = publishCalls.length;
|
||||
|
||||
invalidateRoleDefaultsCache();
|
||||
|
||||
// Give the microtask queue one tick (publish returns a promise).
|
||||
await Promise.resolve();
|
||||
|
||||
const newPublishes = publishCalls.slice(countBefore);
|
||||
expect(newPublishes.length).toBe(1);
|
||||
expect(newPublishes[0]!.channel).toBe("capakraken:rbac-invalidate");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user