fix(core-dsr): scope DSR operations to the caller's own subject
Non-admin callers may export/rectify/restrict/delete ONLY their own subjectId; a mismatch is rejected with FORBIDDEN instead of being honored verbatim (IDOR, audit finding A1). Cross-subject operations require the admin role; cascade-hard stays admin-only on top. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -208,6 +208,93 @@ describe("dsrRouter.restrict", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter subject scoping (A1 — IDOR)", () => {
|
||||
it("rejects a non-admin export for another subject with FORBIDDEN", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.export({ subjectId: "bob", format: "json" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(binding.dataExport.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a non-admin delete for another subject with FORBIDDEN", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.delete({ subjectId: "bob", mode: "soft" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(binding.dataDelete.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a non-admin rectify for another subject with FORBIDDEN", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.rectify({
|
||||
subjectId: "bob",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "x",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(binding.dataRectify.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a non-admin restrict for another subject with FORBIDDEN", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.restrict({ subjectId: "bob", granted: true }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(binding.processingRestriction.sets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a non-admin user without an id acting on any subject", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, { roles: ["user"] });
|
||||
await expect(
|
||||
caller.export({ subjectId: "alice", format: "json" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("allows a non-admin to act on themselves for every operation", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await caller.export({ subjectId: "alice", format: "json" });
|
||||
await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||
await caller.rectify({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice",
|
||||
});
|
||||
await caller.restrict({ subjectId: "alice", granted: true });
|
||||
expect(binding.dataExport.calls).toHaveLength(1);
|
||||
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||
expect(binding.dataRectify.calls).toHaveLength(1);
|
||||
expect(binding.processingRestriction.sets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows an admin to act cross-subject on every operation", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, adminUser);
|
||||
await caller.export({ subjectId: "alice", format: "json" });
|
||||
await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||
await caller.rectify({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice",
|
||||
});
|
||||
await caller.restrict({ subjectId: "alice", granted: true });
|
||||
expect(binding.dataExport.calls).toHaveLength(1);
|
||||
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||
expect(binding.dataRectify.calls).toHaveLength(1);
|
||||
expect(binding.processingRestriction.sets).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user