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:
2026-04-17 13:01:15 +02:00
parent 23c6e0e04b
commit e2dddd30df
5 changed files with 440 additions and 4 deletions
@@ -49,12 +49,20 @@ vi.mock("otpauth", () => {
const createCaller = createCallerFactory(userRouter);
function createAdminCaller(db: Record<string, unknown>) {
// Provide a no-op activeSession stub by default — some mutation paths
// (setPermissions / resetPermissions / updateRole, see ticket #57) now
// invalidate active sessions to force a re-login on privilege changes.
// Individual tests can override by passing their own `activeSession` key.
const dbWithDefaults = {
activeSession: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
...db,
};
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
db: dbWithDefaults as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,