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:
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { makeAuditLogger } from "../lib/audit-helpers.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
import { invalidateRoleDefaultsCache } from "../trpc.js";
|
||||
|
||||
export const CreateUserInputSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -205,6 +206,16 @@ export async function updateUserRole(
|
||||
select: { id: true, name: true, email: true, systemRole: true },
|
||||
});
|
||||
|
||||
// Force re-login: a role change (especially a demotion) must revoke
|
||||
// currently-issued JWTs. Our JWT middleware checks the jti against
|
||||
// ActiveSession on every tRPC call, so wiping these rows invalidates
|
||||
// every outstanding session for this user on the next request.
|
||||
if (before.systemRole !== updated.systemRole) {
|
||||
await ctx.db.activeSession.deleteMany({ where: { userId: updated.id } });
|
||||
// Also nuke the per-instance role-defaults cache (cross-node via pub/sub).
|
||||
invalidateRoleDefaultsCache();
|
||||
}
|
||||
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: updated.id,
|
||||
@@ -385,6 +396,12 @@ export async function setUserPermissions(
|
||||
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||
});
|
||||
|
||||
// Permission overrides can remove access — force affected sessions to
|
||||
// re-authenticate so the new override set is applied immediately rather
|
||||
// than waiting for the TTL. Cross-node cache invalidation via pub/sub.
|
||||
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
|
||||
invalidateRoleDefaultsCache();
|
||||
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: input.userId,
|
||||
@@ -422,6 +439,11 @@ export async function resetUserPermissions(
|
||||
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||
});
|
||||
|
||||
// Reset may remove privileges that were `granted` via override — force
|
||||
// re-login so the regression applies on the next request.
|
||||
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
|
||||
invalidateRoleDefaultsCache();
|
||||
|
||||
audit({
|
||||
entityType: "User",
|
||||
entityId: input.userId,
|
||||
|
||||
Reference in New Issue
Block a user