feat(web-next): resolve the session user + live compliance context
The tRPC createContext was () => ({}) — the mounted dsr/consent routers
401'd every call and the dsr singleton stub threw (audit finding A11).
createTrpcContext now accepts an app resolveUser hook; web-next resolves
the session cookie through the auth feature's validateSession (denylist
included) plus a role snapshot, and threads bindProductionDsr/Consent
(or dev-seed) bindings into every request. The dsr router resolves its
binding from ctx.dsrBinding per request instead of a throwing proxy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -295,15 +295,40 @@ describe("dsrRouter subject scoping (A1 — IDOR)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter singleton guard", () => {
|
||||
it("throws when procedures are called without a real DsrBinding", async () => {
|
||||
// The singleton uses a Proxy that throws on any binding property access.
|
||||
// Procedures access binding lazily, so the Proxy error surfaces at call time.
|
||||
describe("dsrRouter singleton (context-time binding, A11)", () => {
|
||||
it("fails loudly when neither ctx.dsrBinding nor a creation binding exists", async () => {
|
||||
const caller = dsrRouter.createCaller({
|
||||
user: authenticatedUser,
|
||||
} as Record<string, unknown>);
|
||||
await expect(
|
||||
caller.export({ subjectId: "alice", format: "json" }),
|
||||
).rejects.toThrow(/dsrRouter singleton/);
|
||||
).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: expect.stringContaining("DsrBinding missing"),
|
||||
});
|
||||
});
|
||||
|
||||
it("serves requests when the app provides ctx.dsrBinding", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = dsrRouter.createCaller({
|
||||
user: authenticatedUser,
|
||||
dsrBinding: binding,
|
||||
} as Record<string, unknown>);
|
||||
const result = await caller.export({ subjectId: "alice", format: "json" });
|
||||
expect(result.subjectId).toBe("alice");
|
||||
expect(binding.dataExport.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prefers ctx.dsrBinding over the creation-time binding", async () => {
|
||||
const creationBinding = makeBinding();
|
||||
const ctxBinding = makeBinding();
|
||||
const router = createDsrRouter(creationBinding as unknown as DsrBinding);
|
||||
const caller = router.createCaller({
|
||||
user: authenticatedUser,
|
||||
dsrBinding: ctxBinding,
|
||||
} as Record<string, unknown>);
|
||||
await caller.export({ subjectId: "alice", format: "json" });
|
||||
expect(ctxBinding.dataExport.calls).toHaveLength(1);
|
||||
expect(creationBinding.dataExport.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,21 +51,43 @@ function userFromCtx(ctx: object): DsrTrpcUser {
|
||||
return (ctx as { user: DsrTrpcUser }).user;
|
||||
}
|
||||
|
||||
/** tRPC context consumed by the DSR router (provided by the app's createContext). */
|
||||
export type DsrRouterContext = {
|
||||
user?: DsrTrpcUser;
|
||||
/** Per-request DSR binding — the app wires bindProductionDsr/bindDevSeedDsr output here. */
|
||||
dsrBinding?: DsrBinding;
|
||||
};
|
||||
|
||||
function bindingFromCtx(ctx: object, fallback?: DsrBinding): DsrBinding {
|
||||
const fromCtx = (ctx as DsrRouterContext).dsrBinding;
|
||||
if (fromCtx) return fromCtx;
|
||||
if (fallback) return fallback;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"DsrBinding missing — provide ctx.dsrBinding from createContext " +
|
||||
"(bindProductionDsr/bindDevSeedDsr) or pass a binding to createDsrRouter",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the DSR tRPC router.
|
||||
*
|
||||
* Capture `binding` at router-creation time. Apps that mount this router
|
||||
* must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`.
|
||||
* The binding is resolved per request from `ctx.dsrBinding` (audit finding
|
||||
* A11 — the mounted router must be live, not a dead stub), falling back to
|
||||
* the optional `binding` captured at router-creation time.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // creation-time binding
|
||||
* const binding = bindProductionDsr({ config, auditLog });
|
||||
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
|
||||
*
|
||||
* // or context-time binding (what apps mounting the `dsrRouter` singleton do)
|
||||
* createContext: () => ({ user, dsrBinding })
|
||||
* ```
|
||||
*/
|
||||
export function createDsrRouter(binding: DsrBinding) {
|
||||
// Handlers are created lazily (inside procedure closures) so that the
|
||||
// dsrRouter singleton proxy doesn't trigger at module init time.
|
||||
export function createDsrRouter(binding?: DsrBinding) {
|
||||
return t.router({
|
||||
export: dsrProcedure
|
||||
.input(
|
||||
@@ -78,7 +100,8 @@ export function createDsrRouter(binding: DsrBinding) {
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||
const res = await createExportHandler(binding.dataExport)(input);
|
||||
const b = bindingFromCtx(ctx, binding);
|
||||
const res = await createExportHandler(b.dataExport)(input);
|
||||
return res.body;
|
||||
}),
|
||||
|
||||
@@ -100,7 +123,8 @@ export function createDsrRouter(binding: DsrBinding) {
|
||||
message: "Admin role required for cascade-hard deletion",
|
||||
});
|
||||
}
|
||||
const res = await createDeleteHandler(binding.dataDelete)(input);
|
||||
const b = bindingFromCtx(ctx, binding);
|
||||
const res = await createDeleteHandler(b.dataDelete)(input);
|
||||
return res.body;
|
||||
}),
|
||||
|
||||
@@ -117,8 +141,9 @@ export function createDsrRouter(binding: DsrBinding) {
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||
const b = bindingFromCtx(ctx, binding);
|
||||
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
|
||||
const res = await createRectifyHandler(binding.dataRectify)(
|
||||
const res = await createRectifyHandler(b.dataRectify)(
|
||||
input as RectifyHandlerInput,
|
||||
);
|
||||
return res.body;
|
||||
@@ -135,29 +160,19 @@ export function createDsrRouter(binding: DsrBinding) {
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
assertSubjectScope(userFromCtx(ctx), input.subjectId);
|
||||
const res = await createRestrictHandler(binding.processingRestriction)(
|
||||
input,
|
||||
);
|
||||
const b = bindingFromCtx(ctx, binding);
|
||||
const res = await createRestrictHandler(b.processingRestriction)(input);
|
||||
return res.body;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience singleton for projects with a single DSR binding instance.
|
||||
* Most callers should use `createDsrRouter(binding)` and pass the binding
|
||||
* explicitly. This export exists for type inference (`DsrRouter`) only.
|
||||
* Router singleton mounted by the app router. It has no creation-time
|
||||
* binding: every procedure resolves `ctx.dsrBinding`, which the app's
|
||||
* `createContext` supplies per request (A11). Calls without a context
|
||||
* binding fail with INTERNAL_SERVER_ERROR at request time.
|
||||
*/
|
||||
export const dsrRouter = createDsrRouter(
|
||||
new Proxy({} as DsrBinding, {
|
||||
get(_target, prop) {
|
||||
if (prop === "then") return undefined; // not a Promise
|
||||
throw new Error(
|
||||
`dsrRouter singleton used without providing a DsrBinding. ` +
|
||||
`Use createDsrRouter(binding) instead.`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
export const dsrRouter = createDsrRouter();
|
||||
|
||||
export type DsrRouter = ReturnType<typeof createDsrRouter>;
|
||||
|
||||
@@ -37,7 +37,7 @@ export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
|
||||
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
||||
|
||||
export { createDsrRouter, dsrRouter } from "./dsr.router";
|
||||
export type { DsrRouter, DsrTrpcUser } from "./dsr.router";
|
||||
export type { DsrRouter, DsrTrpcUser, DsrRouterContext } from "./dsr.router";
|
||||
|
||||
export type { HandlerResponse } from "./handlers/handler-types";
|
||||
export { createExportHandler } from "./handlers/export-handler";
|
||||
|
||||
Reference in New Issue
Block a user