Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

scim-account-scope.test.tsBlame189 lines · 1 contributor
f06fc8fccantynz-alt1/**
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
34import { describe, it, expect } from "bun:test";
35import { readFileSync } from "node:fs";
36import { join } from "node:path";
37import { mayScimDisableAccount } from "../routes/scim";
38
39const ORG_A = "11111111-1111-1111-1111-111111111111";
40const ORG_B = "22222222-2222-2222-2222-222222222222";
41
42describe("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
103function 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. */
117function 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
127describe("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);
6ae664dccantynz-alt137 expect(body).toContain("canScimManageAccount(");
138 expect(body.indexOf("canScimManageAccount(")).toBeLessThan(
f06fc8fccantynz-alt139 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);
6ae664dccantynz-alt150 expect(body).toContain("canScimManageAccount(");
151 expect(body.indexOf("canScimManageAccount(")).toBeLessThan(
f06fc8fccantynz-alt152 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);
6ae664dccantynz-alt163 expect(body).toContain("canScimManageAccount(");
164 expect(body.indexOf("canScimManageAccount(")).toBeLessThan(
f06fc8fccantynz-alt165 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(
6ae664dccantynz-alt170 body.indexOf("canScimManageAccount(")
f06fc8fccantynz-alt171 );
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});