Organization SSO and multi-user tenancy
A company using ZeNorm logs in through their identity provider and lands in a shared organization workspace alongside their colleagues, not a personal silo. The organization membership is proven by the Clerk JWT org claim — the client never asserts it. Personal workspaces continue unchanged for everyone else; no existing user is migrated.
“A company told us their security team won't let them use anything without single sign-on.”
The grilling
A company told us their security team won't let them use anything without single sign-on. Before you ask me anything: go read how authentication actually works here. I want to know what token shapes are accepted, how a user is mapped to an org, and — this is the part I care most about — whether the code does anything deliberate with an organization claim on the token. Read the comments, not just the logic. Then report what you found and grill me. Feature scale: what it takes for this product to be adoptable by a company that mandates SSO.
找到你了。以下是报告。 --- ## 身份验证现状:完整审查 ### 令牌形状 API 在 `auth.ts` 中接受 4 种 auth 路径: 1. **Clerk 会话 JWT** — 通过 `createRemoteJWKSet(Clerk 的 JWKS URL)` 验证。标准 `jwtVerify`,含可选的 `issuer`/`audience` 约束。 2. **自定义 JWT** — 通过共享的 `CLERK_JWT_SECRET`(HMAC)验证。 3. **Clerk 不透明 OAuth 令牌** — `oat_` 前缀,通过 `POST https://api.clerk.com/v1/oauth_applications/access_tokens/verify` 内省。用于公共 CLI,**没有**负载声明——始终返回个人工作空间。 4. **开发绕过** — `x-zenorm-user-id`/`x-zenorm-org-id`/`x-zenorm-admin` 标头(仅限 dev/test)。 ### 用户 → 组织映射 这是关键部分。`authFromClaims()` 函数包含一个**非常刻意的注释**: > ZeNorm 没有组织支持——每个用户都是一个独立的个人工作空间。我们**故意忽略**任何 Clerk 组织声明(`o`/`org_id`):碰巧拥有活跃 Clerk 组织的用户绝不能路由到组织类型的工作空间,因为这会隐藏个人计费(settings.tsx 在 `billingType === "personal"` 上设置门控)。 它总是创建 `personal:{clerkUserId}` 作为 `orgId`。每个用户都是一个孤岛。 ### 数据库模式 `orgs` 表有一个 `billingType` 列(`"personal" | "organization"`)和一个 `clerk_org_id` 列。两者都已存在于模式中,但**从未被应用程序代码写入或读取**——它们是预留的。`spec_collaborators` 表也存在,支持每个 spec 的协作者访问控制。 ### Web 应用 Clerk 已连接(`ClerkProvider`,`publishableKey`),但 web 端没有调用 `useOrganization()`,没有组织选择器,没有组织切换。 --- **目前的产品已经通过了 SAML/OIDC 认证协议**——Clerk 代表你处理这些。阻止安全团队实现 SSO 适配的不是认证,而是**组织映射**:当 Bob 的公司通过他们的 IdP 登录时,他会进入自己的个人工作空间,而不是他公司的共享工作空间。他不能与同事共享 spec,管理员无法看到他的使用情况,并且每个人都在独立的计费轨道上。 所以问题是:你需要多大程度地改变这一点? 报告已就位,问题已提出。在填写目标区块之前,我先等待您的回答。
Files the agent read
- read_repo_structure whole repo
- ls whole repo
- ls apps
- ls packages
- grep sso\|SSO\|OIDC\|SAML\|single.?sign.?on\|\borg\b.*claim\|\bclaim\b.*org\|organization.*claim\|claim.*organization\|id_token\|access_token.*org
- grep apps/api
- grep apps/web
- grep packages
- ls apps/api
- ls apps/web
- ls packages/zero
- ls apps/api/src
- ls apps/web/src
- ls packages/zero/src
- read apps/api/src/auth.ts
- read apps/api/src/org-id.ts
- read packages/zero/src/schema.ts
- grep apps/api
- grep apps/web
- grep apps/web
- grep apps/web/src
- read .env.example
- read apps/web/src/app.tsx
- read apps/web/src/routes
- ls apps/web/src/routes
- grep apps/web/src
- ls apps/web/src/routes/dashboard
- grep apps/web/src/routes/dashboard
- grep apps/web/src/routes/dashboard/settings.tsx
- grep apps/web/src
- grep apps/web/src
- grep apps/api
- ls apps/api/src/routes
That's the answer I was afraid of and I'm glad you found it rather than assuming. The code deliberately ignores the org claim on the token and routes every user to a personal workspace derived from their user id. So there is no multi-user org tenancy in the auth path at all — that's a decision someone made and commented, not an oversight. Which means you're right to ask what you asked, but I'm rejecting your recommendation. You proposed we scope this to wiring up a SAML connection and leave tenancy for later. That's backwards and it would produce a feature that appears to work and doesn't. If I add SSO while every user still lands in their own personal workspace, then five people from the same company sign in through their identity provider and get five disconnected workspaces that can't see each other's specs. They'd have bought "our company uses ZeNorm" and received "five people have separate accounts with a fancier login". The SSO is the visible part; the tenancy is the feature. Grill me on that, not on the protocol.
You're right, and I stand corrected. "SSO works and users land in their own silos" would ship a worse experience than no SSO at all — it looks like a feature but acts like a bug. The real change is: **read the org claim, route into an org workspace, and make the org multi-user.** That's a fundamentally different auth model than the current code supports. A few things fall out of that which I need to pin down before I can scope properly. 没有冒进——在进一步之前,我需要知道边界。自动加入 vs. 仅限受邀者从根本上改变了构建内容。
Here's my ruling on scope, and it's deliberately narrow. This feature is: a verified organization is the tenancy boundary, and identity-provider-authenticated users land inside it. The constraint that governs everything else — record this one carefully — is that a user's organization must be determined by the verified token, never by anything the client asserts, and never by a header a request can set for itself. The existing dev-bypass headers are exactly the shape of thing that must not become a production path. If org membership can be claimed rather than proven, SSO is worse than no SSO, because now there's a trusted-looking boundary that isn't one. Second constraint: the personal workspace does not go away and existing users do not get migrated. Someone signing in the way they always have keeps landing exactly where they landed yesterday. Every account in this product today is personal, and a change that silently relocates people's work is unrecoverable. Org membership is additive. Now — I'm aware I'm asking for the thing the codebase most explicitly decided not to do. I want that surfaced in the spec honestly rather than glossed. If honouring the org claim means changing a documented decision in the auth layer, say so in the constraints.
Got it. Let me write that down and then resolve the one remaining scope question before I lock intent. The gate says I extrapolated beyond what you said. Let me re-anchor directly to your words. Blocks captured. Now — the most important unresolved scope question before I can lock intent. 继续——你拥有的组织有多少是真实存在的(在 Clerk 中),有多少是由 ZeNorm 自动创建的,这对架构有很大影响。
insert_blocksunsupported scope boundaryRequirement invents high-risk non-goal(s) absent from authoritative PM evidence: failure recovery. A primary scenario does not exclude adjacent behavior; remove the boundary or ask the PM to decide it explicitly.
Remove or reword the unsupported claim: keep only content the PM stated, selected, or the repository verified. If the claim is load-bearing, ask the PM instead of asserting it. This gate family will not block again this session for a rewrite without the claim — but resubmitting this same rejected content is rejected every time; retry once without the claim, then move on; do not loop on rewording. Block inserts are atomic: nothing from this call was committed. Retry the batch without the rejected claim.
Last decisions. SCIM and automated provisioning: non-goal, explicitly, with reasoning. Directory sync is a second product with its own lifecycle and failure modes — deprovisioning bugs are how a former employee keeps access — and I'd rather have no SCIM than a half one. A user is provisioned on first successful sign-in and removed by a human. No per-org authentication policy either. No "this org may only use SAML", no enforced-SSO toggle that can lock an admin out of their own workspace. That's a support nightmare at our size. And one acceptance criterion I want stated in a checkable way: it must be demonstrable that a user carrying a token asserting membership of an org they don't belong to cannot read a single row from it. Not "authorization is enforced" — an actual attempt that's refused. Score acceptance and cut the tasks.
From your answer, I read the implied provisioning model: **auto-provision on first sign-in, no SCIM, no per-org auth policy.** Let me lock all of that in. All sections at 0.93 — ready for tasks. This is one coherent capability: when a user authenticates via their company's IdP, the Clerk JWT's org claim is honored, the org auto-provisions on first touch, and all data is scoped to that org. No separate UI task — the capability is the auth/tenancy change. Now the trace graph. Spec is done. Here's what shipped: **Title:** Organization SSO and multi-user tenancy **Summary:** A company using ZeNorm logs in through their identity provider and lands in a shared organization workspace alongside their colleagues, not a personal silo. The organization membership is proven by the Clerk JWT org claim — the client never asserts it. Personal workspaces continue unchanged for everyone else; no existing user is migrated. **1 task:** *Honor Clerk org claim in auth context with auto-provisioned tenancy* — modifies `authFromClaims()` in `apps/api/src/auth.ts` to read the `org_id`/`o` claim, auto-provision the `orgs` row on first encounter, and route the user to the org workspace. No-claim users stay on the personal path. Tracers cover all four acceptance criteria. **Key boundaries captured in blocks:** - Token is the only source of org truth (no client-asserted headers) - The deliberate org-claim ignore in the codebase is explicitly reversed - No SCIM, no per-org auth policy enforcement - No existing data migration — org membership is additive - Cross-org isolation must be demonstrable as a live refusal
insert_blocksunsupported authoritative mechanicRequirement invents surface/queue/identifier/protocol mechanic(s) absent from authoritative PM or original-request evidence: exact protocol status 403. Keep the confirmed generic outcome, or ask only if this exact mechanic is product-owned.
Remove or reword the unsupported claim: keep only content the PM stated, selected, or the repository verified. If the claim is load-bearing, ask the PM instead of asserting it. This gate family will not block again this session for a rewrite without the claim — but resubmitting this same rejected content is rejected every time; retry once without the claim, then move on; do not loop on rewording. Block inserts are atomic: nothing from this call was committed. Retry the batch without the rejected claim.
create_tasktask description sprawlThis task description runs 5 sentences — past the 1-3 sentence capability handoff. Keep the outcome, the surfaces it touches, and what proves it works; cut restated acceptance assertions, step lists, and implementation detail the blocks and trace graph already carry.
update_tasktask description sprawlThis task description runs 4 sentences — past the 1-3 sentence capability handoff. Keep the outcome, the surfaces it touches, and what proves it works; cut restated acceptance assertions, step lists, and implementation detail the blocks and trace graph already carry.
What it asked
当公司通过 SSO 登录时,你希望发生什么?
- A每个公司一个组织工作空间Agent's pick
公司 IdP 中的所有用户都路由到共享的公司组织,包含多个成员、管理员和统一计费
- B个人工作空间加一个 SSO 门控
用户仍拥有个人工作空间,但登录本身通过 IdP 进行。没有多成员组织,只是一个认证协议要求
- C混合:SSO 强制 + 可选的组织
IdP 用户最终进入组织工作空间,但小型/测试用户仍可选择个人工作空间;计费跟随组织
That's the answer I was afraid of and I'm glad you found it rather than assuming. The code deliberately ignores the org claim on the token and routes every user to a personal workspace derived from their user id. So there is no multi-user org tenancy in the auth path at all — that's a decision someone made and commented, not an oversight. Which means you're right to ask what you asked, but I'm rejecting your recommendation. You proposed we scope this to wiring up a SAML connection and leave tenancy for later. That's backwards and it would produce a feature that appears to work and doesn't. If I add SSO while every user still lands in their own personal workspace, then five people from the same company sign in through their identity provider and get five disconnected workspaces that can't see each other's specs. They'd have bought "our company uses ZeNorm" and received "five people have separate accounts with a fancier login". The SSO is the visible part; the tenancy is the feature. Grill me on that, not on the protocol.
谁自动加入该组织,还是需要邀请?
- A自动:任何 IdP 用户加入Agent's pick
通过公司 IdP 进行身份验证的每个人都会自动进入公司组织工作空间。无需邀请
- B仅限受邀者:管理员控制成员资格
组织管理员邀请用户加入组织工作空间;仅 SSO 是不够的
- C自动进入,但具有角色分配
IdP 用户进入该组织,但具有默认角色(例如查看者/编辑者),由管理员提升
Here's my ruling on scope, and it's deliberately narrow. This feature is: a verified organization is the tenancy boundary, and identity-provider-authenticated users land inside it. The constraint that governs everything else — record this one carefully — is that a user's organization must be determined by the verified token, never by anything the client asserts, and never by a header a request can set for itself. The existing dev-bypass headers are exactly the shape of thing that must not become a production path. If org membership can be claimed rather than proven, SSO is worse than no SSO, because now there's a trusted-looking boundary that isn't one. Second constraint: the personal workspace does not go away and existing users do not get migrated. Someone signing in the way they always have keeps landing exactly where they landed yesterday. Every account in this product today is personal, and a change that silently relocates people's work is unrecoverable. Org membership is additive. Now — I'm aware I'm asking for the thing the codebase most explicitly decided not to do. I want that surfaced in the spec honestly rather than glossed. If honouring the org claim means changing a documented decision in the auth layer, say so in the constraints.
当第一个 IdP 用户登录时如何创建组织?
- A首次登录时自动配置Agent's pick
当第一个 IdP 用户使用 JWT 中的 org_id 登录时,系统将在 orgs 表中创建该组织行
- B仅限管理员预先创建
组织必须先由管理员在 ZeNorm 中创建(或通过 Clerk 配置),然后用户才能登录到该组织
- CClerk 映射,ZeNorm 自动创建
如果 Clerk org 存在于 Clerk 中,ZeNorm 会在第一个用户登录时自动将其映射到 ZeNorm org。无需手动步骤
Last decisions. SCIM and automated provisioning: non-goal, explicitly, with reasoning. Directory sync is a second product with its own lifecycle and failure modes — deprovisioning bugs are how a former employee keeps access — and I'd rather have no SCIM than a half one. A user is provisioned on first successful sign-in and removed by a human. No per-org authentication policy either. No "this org may only use SAML", no enforced-SSO toggle that can lock an admin out of their own workspace. That's a support nightmare at our size. And one acceptance criterion I want stated in a checkable way: it must be demonstrable that a user carrying a token asserting membership of an org they don't belong to cannot read a single row from it. Not "authorization is enforced" — an actual attempt that's refused. Score acceptance and cut the tasks.
Where the server refused
Each of these rejected the agent's own tool call. The agent had to fix its output and try again before the spec could advance — this is the gate, not a prompt the model can talk past.
insert_blocksunsupported scope boundaryRequirement invents high-risk non-goal(s) absent from authoritative PM evidence: failure recovery. A primary scenario does not exclude adjacent behavior; remove the boundary or ask the PM to decide it explicitly.
Remove or reword the unsupported claim: keep only content the PM stated, selected, or the repository verified. If the claim is load-bearing, ask the PM instead of asserting it. This gate family will not block again this session for a rewrite without the claim — but resubmitting this same rejected content is rejected every time; retry once without the claim, then move on; do not loop on rewording. Block inserts are atomic: nothing from this call was committed. Retry the batch without the rejected claim.
insert_blocksunsupported authoritative mechanicRequirement invents surface/queue/identifier/protocol mechanic(s) absent from authoritative PM or original-request evidence: exact protocol status 403. Keep the confirmed generic outcome, or ask only if this exact mechanic is product-owned.
Remove or reword the unsupported claim: keep only content the PM stated, selected, or the repository verified. If the claim is load-bearing, ask the PM instead of asserting it. This gate family will not block again this session for a rewrite without the claim — but resubmitting this same rejected content is rejected every time; retry once without the claim, then move on; do not loop on rewording. Block inserts are atomic: nothing from this call was committed. Retry the batch without the rejected claim.
What it had to clear
Every section had to score at or above 0.90 before create_task would run at all. This is the gate.
Four goal blocks cover: org workspace for IdP users, token-proven membership, personal workspace preservation, and auto-provision on first login. Actor is clear (IdP-authenticated user). Non-goals documented as constraints (no SCIM, no per-org auth policy). Scope boundary matches PM decisions.
Five constraint blocks cover: token-only org truth (security boundary), reversal of documented a11n decision (honesty), no migration (operational safety), no SCIM (scope boundary), and no per-org auth policy (supportability). Tradeoffs are named explicitly.
Four acceptance criteria cover each goal: IdP landing (goal 1), non-SSO unchanged (goal 3), cross-org isolation (goal 2), auto-provision (goal 4). Each is behaviorally observable — a live request demonstrates the outcome.
The spec it produced
How it hangs together
Org membership proven by token
- constrainsToken is the only source of org truth
- validatesIdP user lands in org workspace
- validatesCross-org isolation
Org workspace for IdP users
- constrainsReversal of deliberate auth decision
- constrainsNo per-org authentication policy
- validatesIdP user lands in org workspace
Auto-provision org on first SSO login
- constrainsReversal of deliberate auth decision
- constrainsNo SCIM or directory sync
- validatesOrg auto-provisioned on first login
Preserve personal workspaces
- constrainsNo existing data migration
- validatesNon-SSO user unchanged
IdP user lands in org workspace
- implementsHonor Clerk org claim in auth context with auto-provisioned tenancy
Non-SSO user unchanged
- implementsHonor Clerk org claim in auth context with auto-provisioned tenancy
Cross-org isolation
- implementsHonor Clerk org claim in auth context with auto-provisioned tenancy
Org auto-provisioned on first login
- implementsHonor Clerk org claim in auth context with auto-provisioned tenancy
What the gate released
Honor Clerk org claim in auth context with auto-provisioned tenancy
Modify `authFromClaims()` in `apps/api/src/auth.ts` to honor the Clerk org claim (`org_id`/`o`) — route the user to the org workspace, auto-provision the `orgs` row on first unknown `clerk_org_id`, and keep no-claim users on the personal path. Update the comment that documented the deliberate org-claim ignore. Automated assertions: - IdP user lands in org workspace — The response to any authenticated request returns data scoped to the org's internal UUID, not to a personal:{userId} workspace - Non-SSO user unchanged — no org_id / o claim) continues to land in their personal workspace — No existing behaviors, billing routes, or spec access are altered - Cross-org isolation - Org auto-provisioned on first login
zenorm workYour coding agent claims the task, implements it against the full spec, and gates itself on typecheck, lint, and tests.
A real request, the questions the agent asked, and every place the server refused its output. No product announcements.