Blame · Line-by-line history
scim-account-scope.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| f06fc8f | 1 | /** |
| 2 | * Regression guard: a SCIM token is an ORG-scoped credential and must never | |
| 3 | * reach a user's PLATFORM-wide account. | |
| 4 | * | |
| 5 | * The bug: all three deprovisioning verbs wrote the global soft-delete flags | |
| 6 | * (`users.deletedAt` / `deletionScheduledFor`) unconditionally — | |
| 7 | * | |
| 8 | * DELETE /scim/v2/:orgId/Users/:userId | |
| 9 | * PATCH ... with `active: false` <- what Okta and Entra actually send | |
| 10 | * PUT ... with `active: false` | |
| 11 | * | |
| 12 | * — after checking only that the target was a member of the caller's org. | |
| 13 | * Since POST silently attaches any pre-existing account matched by email, | |
| 14 | * the chain was: provision victim@example.com into an org you control, then | |
| 15 | * deprovision them, and the victim's ENTIRE account — personal repos and | |
| 16 | * every other org — is scheduled for deletion in 30 days. Minting the token | |
| 17 | * needs only org-admin on an org you created yourself. | |
| 18 | * | |
| 19 | * Not instant takedown: login cancels a pending deletion (auth.tsx), so a | |
| 20 | * victim who signs in inside the window recovers. A dormant account does not. | |
| 21 | * | |
| 22 | * The DELETE handler's own docblock said "deprovision (disable, keep data)" | |
| 23 | * and its own inline comment said "preserves user account + git history" — | |
| 24 | * the code directly under both did the opposite. | |
| 25 | * | |
| 26 | * Note on scope: POST attaching a pre-existing account without consent is | |
| 27 | * NOT fixed here, because it is not SCIM-specific — the ordinary org invite | |
| 28 | * route (orgs.tsx) also inserts membership directly with no acceptance step. | |
| 29 | * That is the platform's existing org model and changing it is a product | |
| 30 | * decision, not a security patch. What is fixed is the escalation from | |
| 31 | * "can add you to my org" to "can delete your account". | |
| 32 | */ | |
| 33 | ||
| 34 | import { describe, it, expect } from "bun:test"; | |
| 35 | import { readFileSync } from "node:fs"; | |
| 36 | import { join } from "node:path"; | |
| 37 | import { mayScimDisableAccount } from "../routes/scim"; | |
| 38 | ||
| 39 | const ORG_A = "11111111-1111-1111-1111-111111111111"; | |
| 40 | const ORG_B = "22222222-2222-2222-2222-222222222222"; | |
| 41 | ||
| 42 | describe("mayScimDisableAccount", () => { | |
| 43 | it("refuses an account the org did not create (the attack)", () => { | |
| 44 | // A real person's pre-existing account: provenance is NULL because it | |
| 45 | // was matched by email, not created by SCIM. | |
| 46 | expect( | |
| 47 | mayScimDisableAccount({ | |
| 48 | provisionedByOrgId: null, | |
| 49 | orgId: ORG_A, | |
| 50 | hasOtherOrgMemberships: false, | |
| 51 | }) | |
| 52 | ).toBe(false); | |
| 53 | }); | |
| 54 | ||
| 55 | it("refuses an account another org's connector created", () => { | |
| 56 | expect( | |
| 57 | mayScimDisableAccount({ | |
| 58 | provisionedByOrgId: ORG_B, | |
| 59 | orgId: ORG_A, | |
| 60 | hasOtherOrgMemberships: false, | |
| 61 | }) | |
| 62 | ).toBe(false); | |
| 63 | }); | |
| 64 | ||
| 65 | it("refuses when the user still belongs to another org", () => { | |
| 66 | // Even our own shadow account: someone else is still relying on it. | |
| 67 | expect( | |
| 68 | mayScimDisableAccount({ | |
| 69 | provisionedByOrgId: ORG_A, | |
| 70 | orgId: ORG_A, | |
| 71 | hasOtherOrgMemberships: true, | |
| 72 | }) | |
| 73 | ).toBe(false); | |
| 74 | }); | |
| 75 | ||
| 76 | it("allows only our own shadow account that nobody else uses", () => { | |
| 77 | expect( | |
| 78 | mayScimDisableAccount({ | |
| 79 | provisionedByOrgId: ORG_A, | |
| 80 | orgId: ORG_A, | |
| 81 | hasOtherOrgMemberships: false, | |
| 82 | }) | |
| 83 | ).toBe(true); | |
| 84 | }); | |
| 85 | ||
| 86 | it("treats an empty provenance string as no provenance", () => { | |
| 87 | expect( | |
| 88 | mayScimDisableAccount({ | |
| 89 | provisionedByOrgId: "", | |
| 90 | orgId: "", | |
| 91 | hasOtherOrgMemberships: false, | |
| 92 | }) | |
| 93 | ).toBe(false); | |
| 94 | }); | |
| 95 | }); | |
| 96 | ||
| 97 | // --- the handlers must actually consult the rule --------------------------- | |
| 98 | // | |
| 99 | // The original bug was an absent check, so asserting the rule alone would not | |
| 100 | // have caught it. Strip ONLY line comments: a block-comment regex eats route | |
| 101 | // paths, which contain a slash followed by a star. | |
| 102 | ||
| 103 | function scimSource(): string { | |
| 104 | const text = readFileSync( | |
| 105 | join(import.meta.dir, "..", "routes", "scim.tsx"), | |
| 106 | "utf8" | |
| 107 | ); | |
| 108 | const stripped = text | |
| 109 | .split("\n") | |
| 110 | .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*")) | |
| 111 | .join("\n"); | |
| 112 | expect(stripped.length).toBeGreaterThan(0); | |
| 113 | return stripped; | |
| 114 | } | |
| 115 | ||
| 116 | /** Slice by anchor, never by character count — handlers move. */ | |
| 117 | function slice(src: string, startAnchor: string, endAnchor: string): string { | |
| 118 | const start = src.indexOf(startAnchor); | |
| 119 | expect(start).toBeGreaterThan(-1); | |
| 120 | const end = src.indexOf(endAnchor, start + startAnchor.length); | |
| 121 | expect(end).toBeGreaterThan(start); | |
| 122 | const body = src.slice(start, end); | |
| 123 | expect(body.length).toBeGreaterThan(0); | |
| 124 | return body; | |
| 125 | } | |
| 126 | ||
| 127 | describe("every SCIM verb gates account-level writes", () => { | |
| 128 | const DEPROVISION_MARKER = "deletionScheduledFor"; | |
| 129 | ||
| 130 | it("PUT gates `active` behind the rule", () => { | |
| 131 | const body = slice( | |
| 132 | scimSource(), | |
| 133 | `scim.put("/scim/v2/:orgId/Users/:userId"`, | |
| 134 | `scim.patch("/scim/v2/:orgId/Users/:userId"` | |
| 135 | ); | |
| 136 | expect(body).toContain(DEPROVISION_MARKER); | |
| 137 | expect(body).toContain("canScimManageAccount"); | |
| 138 | expect(body.indexOf("canScimManageAccount")).toBeLessThan( | |
| 139 | body.indexOf(DEPROVISION_MARKER) | |
| 140 | ); | |
| 141 | }); | |
| 142 | ||
| 143 | it("PATCH gates `active` behind the rule", () => { | |
| 144 | const body = slice( | |
| 145 | scimSource(), | |
| 146 | `scim.patch("/scim/v2/:orgId/Users/:userId"`, | |
| 147 | `scim.delete("/scim/v2/:orgId/Users/:userId"` | |
| 148 | ); | |
| 149 | expect(body).toContain(DEPROVISION_MARKER); | |
| 150 | expect(body).toContain("canScimManageAccount"); | |
| 151 | expect(body.indexOf("canScimManageAccount")).toBeLessThan( | |
| 152 | body.indexOf(DEPROVISION_MARKER) | |
| 153 | ); | |
| 154 | }); | |
| 155 | ||
| 156 | it("DELETE gates the soft-delete behind the rule", () => { | |
| 157 | const body = slice( | |
| 158 | scimSource(), | |
| 159 | `scim.delete("/scim/v2/:orgId/Users/:userId"`, | |
| 160 | `scim.get("/scim/v2/:orgId/ServiceProviderConfig"` | |
| 161 | ); | |
| 162 | expect(body).toContain(DEPROVISION_MARKER); | |
| 163 | expect(body).toContain("canScimManageAccount"); | |
| 164 | expect(body.indexOf("canScimManageAccount")).toBeLessThan( | |
| 165 | body.indexOf(DEPROVISION_MARKER) | |
| 166 | ); | |
| 167 | // Membership removal stays unconditional — that IS the deprovision. | |
| 168 | expect(body).toContain("delete(orgMembers)"); | |
| 169 | expect(body.indexOf("delete(orgMembers)")).toBeLessThan( | |
| 170 | body.indexOf("canScimManageAccount") | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 174 | it("POST stamps provenance so shadow accounts are identifiable", () => { | |
| 175 | const body = slice( | |
| 176 | scimSource(), | |
| 177 | `scim.post("/scim/v2/:orgId/Users"`, | |
| 178 | `scim.get("/scim/v2/:orgId/Users/:userId"` | |
| 179 | ); | |
| 180 | // Stamped on the create branch only. If it were also applied to the | |
| 181 | // matched-existing branch, every pre-existing account an org enrolled | |
| 182 | // would become deletable by that org — the whole bug, reintroduced. | |
| 183 | expect(body).toContain("scimProvisionedByOrgId: orgId"); | |
| 184 | expect(body.split("scimProvisionedByOrgId").length - 1).toBe(1); | |
| 185 | expect(body.indexOf("scimProvisionedByOrgId")).toBeGreaterThan( | |
| 186 | body.indexOf("insert(users)") | |
| 187 | ); | |
| 188 | }); | |
| 189 | }); |