Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepfix/audit-sweep-2026-07-26gatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
scim-account-scope.test.ts6.6 KB · 189 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
/**
 * Regression guard: a SCIM token is an ORG-scoped credential and must never
 * reach a user's PLATFORM-wide account.
 *
 * The bug: all three deprovisioning verbs wrote the global soft-delete flags
 * (`users.deletedAt` / `deletionScheduledFor`) unconditionally —
 *
 *   DELETE /scim/v2/:orgId/Users/:userId
 *   PATCH  ... with `active: false`   <- what Okta and Entra actually send
 *   PUT    ... with `active: false`
 *
 * — after checking only that the target was a member of the caller's org.
 * Since POST silently attaches any pre-existing account matched by email,
 * the chain was: provision victim@example.com into an org you control, then
 * deprovision them, and the victim's ENTIRE account — personal repos and
 * every other org — is scheduled for deletion in 30 days. Minting the token
 * needs only org-admin on an org you created yourself.
 *
 * Not instant takedown: login cancels a pending deletion (auth.tsx), so a
 * victim who signs in inside the window recovers. A dormant account does not.
 *
 * The DELETE handler's own docblock said "deprovision (disable, keep data)"
 * and its own inline comment said "preserves user account + git history" —
 * the code directly under both did the opposite.
 *
 * Note on scope: POST attaching a pre-existing account without consent is
 * NOT fixed here, because it is not SCIM-specific — the ordinary org invite
 * route (orgs.tsx) also inserts membership directly with no acceptance step.
 * That is the platform's existing org model and changing it is a product
 * decision, not a security patch. What is fixed is the escalation from
 * "can add you to my org" to "can delete your account".
 */

import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { mayScimDisableAccount } from "../routes/scim";

const ORG_A = "11111111-1111-1111-1111-111111111111";
const ORG_B = "22222222-2222-2222-2222-222222222222";

describe("mayScimDisableAccount", () => {
  it("refuses an account the org did not create (the attack)", () => {
    // A real person's pre-existing account: provenance is NULL because it
    // was matched by email, not created by SCIM.
    expect(
      mayScimDisableAccount({
        provisionedByOrgId: null,
        orgId: ORG_A,
        hasOtherOrgMemberships: false,
      })
    ).toBe(false);
  });

  it("refuses an account another org's connector created", () => {
    expect(
      mayScimDisableAccount({
        provisionedByOrgId: ORG_B,
        orgId: ORG_A,
        hasOtherOrgMemberships: false,
      })
    ).toBe(false);
  });

  it("refuses when the user still belongs to another org", () => {
    // Even our own shadow account: someone else is still relying on it.
    expect(
      mayScimDisableAccount({
        provisionedByOrgId: ORG_A,
        orgId: ORG_A,
        hasOtherOrgMemberships: true,
      })
    ).toBe(false);
  });

  it("allows only our own shadow account that nobody else uses", () => {
    expect(
      mayScimDisableAccount({
        provisionedByOrgId: ORG_A,
        orgId: ORG_A,
        hasOtherOrgMemberships: false,
      })
    ).toBe(true);
  });

  it("treats an empty provenance string as no provenance", () => {
    expect(
      mayScimDisableAccount({
        provisionedByOrgId: "",
        orgId: "",
        hasOtherOrgMemberships: false,
      })
    ).toBe(false);
  });
});

// --- the handlers must actually consult the rule ---------------------------
//
// The original bug was an absent check, so asserting the rule alone would not
// have caught it. Strip ONLY line comments: a block-comment regex eats route
// paths, which contain a slash followed by a star.

function scimSource(): string {
  const text = readFileSync(
    join(import.meta.dir, "..", "routes", "scim.tsx"),
    "utf8"
  );
  const stripped = text
    .split("\n")
    .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*"))
    .join("\n");
  expect(stripped.length).toBeGreaterThan(0);
  return stripped;
}

/** Slice by anchor, never by character count — handlers move. */
function slice(src: string, startAnchor: string, endAnchor: string): string {
  const start = src.indexOf(startAnchor);
  expect(start).toBeGreaterThan(-1);
  const end = src.indexOf(endAnchor, start + startAnchor.length);
  expect(end).toBeGreaterThan(start);
  const body = src.slice(start, end);
  expect(body.length).toBeGreaterThan(0);
  return body;
}

describe("every SCIM verb gates account-level writes", () => {
  const DEPROVISION_MARKER = "deletionScheduledFor";

  it("PUT gates `active` behind the rule", () => {
    const body = slice(
      scimSource(),
      `scim.put("/scim/v2/:orgId/Users/:userId"`,
      `scim.patch("/scim/v2/:orgId/Users/:userId"`
    );
    expect(body).toContain(DEPROVISION_MARKER);
    expect(body).toContain("canScimManageAccount(");
    expect(body.indexOf("canScimManageAccount(")).toBeLessThan(
      body.indexOf(DEPROVISION_MARKER)
    );
  });

  it("PATCH gates `active` behind the rule", () => {
    const body = slice(
      scimSource(),
      `scim.patch("/scim/v2/:orgId/Users/:userId"`,
      `scim.delete("/scim/v2/:orgId/Users/:userId"`
    );
    expect(body).toContain(DEPROVISION_MARKER);
    expect(body).toContain("canScimManageAccount(");
    expect(body.indexOf("canScimManageAccount(")).toBeLessThan(
      body.indexOf(DEPROVISION_MARKER)
    );
  });

  it("DELETE gates the soft-delete behind the rule", () => {
    const body = slice(
      scimSource(),
      `scim.delete("/scim/v2/:orgId/Users/:userId"`,
      `scim.get("/scim/v2/:orgId/ServiceProviderConfig"`
    );
    expect(body).toContain(DEPROVISION_MARKER);
    expect(body).toContain("canScimManageAccount(");
    expect(body.indexOf("canScimManageAccount(")).toBeLessThan(
      body.indexOf(DEPROVISION_MARKER)
    );
    // Membership removal stays unconditional — that IS the deprovision.
    expect(body).toContain("delete(orgMembers)");
    expect(body.indexOf("delete(orgMembers)")).toBeLessThan(
      body.indexOf("canScimManageAccount(")
    );
  });

  it("POST stamps provenance so shadow accounts are identifiable", () => {
    const body = slice(
      scimSource(),
      `scim.post("/scim/v2/:orgId/Users"`,
      `scim.get("/scim/v2/:orgId/Users/:userId"`
    );
    // Stamped on the create branch only. If it were also applied to the
    // matched-existing branch, every pre-existing account an org enrolled
    // would become deletable by that org — the whole bug, reintroduced.
    expect(body).toContain("scimProvisionedByOrgId: orgId");
    expect(body.split("scimProvisionedByOrgId").length - 1).toBe(1);
    expect(body.indexOf("scimProvisionedByOrgId")).toBeGreaterThan(
      body.indexOf("insert(users)")
    );
  });
});