Skip to content

Commit d9209f9

Browse files
improvement(governance): workspace-org invitation system consolidation (#4230)
* workspace re-org checkpoint * admin route reconciliation * checkpoint consistency fixes * prep merge * regen migration * checkpoint * code cleanup * update docs * add feature for owner to leave + admin route * address comments * fix new account race * address comments
1 parent 2ae1ad2 commit d9209f9

File tree

102 files changed

+23869
-4209
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

102 files changed

+23869
-4209
lines changed

apps/docs/content/docs/en/execution/costs.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,17 @@ By default, your usage is capped at the credits included in your plan. To allow
308308

309309
## Plan Limits
310310

311+
### Workspaces
312+
313+
| Plan | Personal Workspaces | Shared (Organization) Workspaces |
314+
|------|---------------------|----------------------------------|
315+
| **Free** | 1 ||
316+
| **Pro** | Up to 3 ||
317+
| **Max** | Up to 10 ||
318+
| **Team / Enterprise** | Unlimited | Unlimited |
319+
320+
Team and Enterprise plans unlock shared workspaces that belong to your organization. Members invited to a shared workspace automatically join the organization and count toward your seat total. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces remain accessible to current members but new invites are disabled until the organization is upgraded again.
321+
311322
### Rate Limits
312323

313324
| Plan | Sync (req/min) | Async (req/min) |

apps/docs/content/docs/en/permissions/roles-and-permissions.mdx

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,31 @@
22
title: "Roles and Permissions"
33
---
44

5+
import { Callout } from 'fumadocs-ui/components/callout'
56
import { Video } from '@/components/ui/video'
67

78
When you invite team members to your organization or workspace, you'll need to choose what level of access to give them. This guide explains what each permission level allows users to do, helping you understand team roles and what access each permission level provides.
89

10+
## Workspaces and Organizations
11+
12+
Sim has two kinds of workspaces:
13+
14+
- **Personal workspaces** live under your individual account. The number you can create depends on your plan.
15+
- **Shared (organization) workspaces** live under an organization and are available on Team and Enterprise plans. Any organization Owner or Admin can create them. Members invited to a shared workspace automatically join the organization and count toward your seat total.
16+
17+
### Workspace Limits by Plan
18+
19+
| Plan | Personal Workspaces | Shared Workspaces |
20+
|------|---------------------|-------------------|
21+
| **Free** | 1 ||
22+
| **Pro** | Up to 3 ||
23+
| **Max** | Up to 10 ||
24+
| **Team / Enterprise** | Unlimited | Unlimited (seat-gated invites) |
25+
26+
<Callout type="info">
27+
When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces stay accessible to current members. New invitations are blocked until the organization is upgraded again.
28+
</Callout>
29+
930
## How to Invite Someone to a Workspace
1031

1132
<div className="mx-auto w-full overflow-hidden rounded-lg">
@@ -88,6 +109,10 @@ Every workspace has one **Owner** (the person who created it) plus any number of
88109
- Can do everything except delete the workspace or remove the owner
89110
- Can be removed from the workspace by the owner or other admins
90111

112+
<Callout type="info">
113+
For shared (organization) workspaces, the organization's Owner and Admins are treated as Admins of every workspace in the organization, even without an explicit per-workspace invite.
114+
</Callout>
115+
91116
---
92117

93118
## Common Scenarios
@@ -145,25 +170,38 @@ Periodically review who has access to what, especially when team members change
145170

146171
## Organization Roles
147172

148-
When inviting someone to your organization, you can assign one of two roles:
173+
An organization has three roles: **Owner**, **Admin**, and **Member**.
174+
175+
### Organization Owner
176+
**What they can do:**
177+
- Everything an Admin can do
178+
- Transfer organization ownership to another user
179+
- Only one Owner exists per organization
149180

150181
### Organization Admin
151182
**What they can do:**
152183
- Invite and remove team members from the organization
153-
- Create new workspaces
154-
- Manage billing and subscription settings
155-
- Access all workspaces within the organization
184+
- Create new shared workspaces under the organization
185+
- Manage billing, seat count, and subscription settings
186+
- Access all shared workspaces within the organization as a workspace Admin
187+
- Promote members to Admin or demote Admins to Member
188+
189+
<Callout type="info">
190+
Owners and Admins have the same day-to-day permissions. The only action reserved for the Owner is transferring ownership.
191+
</Callout>
156192

157193
### Organization Member
158194
**What they can do:**
159-
- Access workspaces they've been specifically invited to
195+
- Access shared workspaces they've been specifically invited to
160196
- View the list of organization members
161-
- Cannot invite new people or manage organization settings
197+
- Cannot invite new people, create shared workspaces, or manage organization settings
162198

163199
import { FAQ } from '@/components/ui/faq'
164200

165201
<FAQ items={[
166-
{ question: "What is the difference between organization roles and workspace permissions?", answer: "Organization roles (Admin or Member) control who can manage the organization itself, including inviting people, creating workspaces, and handling billing. Workspace permissions (Read, Write, Admin) control what a user can do within a specific workspace, such as viewing, editing, or managing workflows. A user needs both an organization role and a workspace permission to work within a workspace." },
202+
{ question: "What is the difference between organization roles and workspace permissions?", answer: "Organization roles (Owner, Admin, or Member) control who can manage the organization itself, including inviting people, creating shared workspaces, and handling billing. Workspace permissions (Read, Write, Admin) control what a user can do within a specific workspace, such as viewing, editing, or managing workflows. A user needs both an organization role and a workspace permission to work within a shared workspace." },
203+
{ question: "How many workspaces can I create?", answer: "Free users get 1 personal workspace. Pro users get up to 3 personal workspaces. Max users get up to 10 personal workspaces. Team and Enterprise plans support unlimited shared workspaces under the organization — new invites are gated by your seat count." },
204+
{ question: "What happens to my shared workspaces if I cancel or downgrade my Team plan?", answer: "Existing shared workspaces remain accessible to current members, but new invitations are disabled until you upgrade back to a Team or Enterprise plan. No workspaces or members are deleted — the organization is simply dormant until billing is re-enabled." },
167205
{ question: "Can I restrict which integrations or model providers a team member can use?", answer: "Yes. Organization admins can create permission groups with fine-grained controls, including restricting allowed integrations and allowed model providers to specific lists. You can also disable access to MCP tools, custom tools, skills, and various platform features like the knowledge base, API keys, or Copilot on a per-group basis." },
168206
{ question: "What happens when a personal environment variable has the same name as a workspace variable?", answer: "The personal environment variable takes priority. When a workflow runs, if both a personal and workspace variable share the same name, the personal value is used. This allows individual users to override shared workspace configuration when needed." },
169207
{ question: "Can an Admin remove the workspace owner?", answer: "No. The workspace owner cannot be removed from the workspace by anyone. Only the workspace owner can delete the workspace or transfer ownership to another user. Admins can do everything else, including inviting and removing other users and managing workspace settings." },

apps/sim/app/(landing)/components/pricing/pricing.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const PRICING_TIERS: PricingTier[] = [
3939
'1,000 credits (trial)',
4040
'5GB file storage',
4141
'3 tables · 1,000 rows each',
42+
'1 personal workspace',
4243
'5 min execution limit',
4344
'7-day log retention',
4445
'CLI/SDK/MCP Access',
@@ -56,6 +57,7 @@ const PRICING_TIERS: PricingTier[] = [
5657
'6,000 credits/mo · +50/day',
5758
'50GB file storage',
5859
'25 tables · 5,000 rows each',
60+
'Up to 3 personal workspaces',
5961
'50 min execution · 150 runs/min',
6062
'Unlimited log retention',
6163
'CLI/SDK/MCP Access',
@@ -73,6 +75,7 @@ const PRICING_TIERS: PricingTier[] = [
7375
'25,000 credits/mo · +200/day',
7476
'500GB file storage',
7577
'25 tables · 5,000 rows each',
78+
'Up to 10 personal workspaces',
7679
'50 min execution · 300 runs/min',
7780
'Unlimited log retention',
7881
'CLI/SDK/MCP Access',
@@ -89,6 +92,7 @@ const PRICING_TIERS: PricingTier[] = [
8992
'Custom credits & infra limits',
9093
'Custom file storage',
9194
'10,000 tables · 1M rows each',
95+
'Unlimited shared workspaces',
9296
'Custom execution limits',
9397
'Unlimited log retention',
9498
'SSO & SCIM · SOC2',
@@ -264,10 +268,12 @@ export default function Pricing() {
264268
Pricing
265269
</h2>
266270
<p className='sr-only'>
267-
Sim pricing: Community plan is free with 1,000 credits and 5GB storage. Pro plan is $25
268-
per month with 6,000 credits and 50GB storage. Max plan is $100 per month with 25,000
269-
credits and 500GB storage. Enterprise pricing is custom with SSO, SCIM, SOC2 compliance,
270-
self-hosting, and dedicated support. All plans include CLI, SDK, and MCP access.
271+
Sim pricing: Community plan is free with 1,000 credits, 5GB storage, and 1 personal
272+
workspace. Pro plan is $25 per month with 6,000 credits, 50GB storage, and up to 3
273+
personal workspaces. Max plan is $100 per month with 25,000 credits, 500GB storage, and
274+
up to 10 personal workspaces. Enterprise pricing is custom with unlimited shared
275+
workspaces, SSO, SCIM, SOC2 compliance, self-hosting, and dedicated support. All plans
276+
include CLI, SDK, and MCP access.
271277
</p>
272278
</div>
273279

apps/sim/app/_shell/providers/session-provider.tsx

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import type React from 'react'
44
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
5+
import { createLogger } from '@sim/logger'
56
import { useQueryClient } from '@tanstack/react-query'
67
import { client } from '@/lib/auth/auth-client'
78
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
@@ -34,6 +35,8 @@ export type SessionHookResult = {
3435

3536
export const SessionContext = createContext<SessionHookResult | null>(null)
3637

38+
const logger = createLogger('SessionProvider')
39+
3740
export function SessionProvider({ children }: { children: React.ReactNode }) {
3841
const [data, setData] = useState<AppSession>(null)
3942
const [isPending, setIsPending] = useState(true)
@@ -49,14 +52,18 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
4952
: await client.getSession()
5053
const session = extractSessionDataFromAuthClientResult(res) as AppSession
5154
setData(session)
55+
return session
5256
} catch (e) {
5357
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
58+
return null
5459
} finally {
5560
setIsPending(false)
5661
}
5762
}, [])
5863

5964
useEffect(() => {
65+
let isCancelled = false
66+
6067
// Check if user was redirected after plan upgrade
6168
const params = new URLSearchParams(window.location.search)
6269
const wasUpgraded = params.get('upgraded') === 'true'
@@ -69,12 +76,51 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
6976
window.history.replaceState({}, '', newUrl)
7077
}
7178

72-
loadSession(wasUpgraded).then(() => {
73-
if (wasUpgraded) {
74-
queryClient.invalidateQueries({ queryKey: ['organizations'] })
75-
queryClient.invalidateQueries({ queryKey: ['subscription'] })
79+
const initializeSession = async () => {
80+
const session = await loadSession(wasUpgraded)
81+
82+
if (!wasUpgraded || isCancelled) {
83+
return
7684
}
77-
})
85+
86+
queryClient.invalidateQueries({ queryKey: ['organizations'] })
87+
queryClient.invalidateQueries({ queryKey: ['subscription'] })
88+
89+
const activeOrganizationId = session?.session?.activeOrganizationId ?? null
90+
if (activeOrganizationId) {
91+
return
92+
}
93+
94+
try {
95+
const response = await fetch('/api/organizations')
96+
if (!response.ok) {
97+
return
98+
}
99+
100+
const orgData = (await response.json()) as {
101+
organizations?: Array<{ id: string }>
102+
}
103+
const organizationId = orgData.organizations?.[0]?.id
104+
105+
if (!organizationId || isCancelled) {
106+
return
107+
}
108+
109+
await client.organization.setActive({ organizationId })
110+
111+
if (!isCancelled) {
112+
await loadSession(true)
113+
}
114+
} catch (error) {
115+
logger.warn('Failed to activate organization after subscription upgrade', { error })
116+
}
117+
}
118+
119+
void initializeSession()
120+
121+
return () => {
122+
isCancelled = true
123+
}
78124
}, [loadSession, queryClient])
79125

80126
useEffect(() => {
@@ -107,9 +153,13 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
107153
.catch(() => {})
108154
}, [data, isPending])
109155

156+
const refetch = useCallback(async () => {
157+
await loadSession()
158+
}, [loadSession])
159+
110160
const value = useMemo<SessionHookResult>(
111-
() => ({ data, isPending, error, refetch: loadSession }),
112-
[data, isPending, error, loadSession]
161+
() => ({ data, isPending, error, refetch }),
162+
[data, isPending, error, refetch]
113163
)
114164

115165
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
3939
},
4040
}))
4141

42-
import { GET } from '@/app/api/auth/[...all]/route'
42+
import { GET, POST } from '@/app/api/auth/[...all]/route'
4343

4444
describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
4545
beforeEach(() => {
@@ -95,3 +95,49 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
9595
expect(json).toEqual({ data: { ok: true } })
9696
})
9797
})
98+
99+
describe('auth catch-all route organization mutations', () => {
100+
beforeEach(() => {
101+
vi.clearAllMocks()
102+
})
103+
104+
it('blocks Better Auth organization mutation endpoints that bypass app lifecycle rules', async () => {
105+
const req = createMockRequest(
106+
'POST',
107+
undefined,
108+
{},
109+
'http://localhost:3000/api/auth/organization/create'
110+
)
111+
112+
const res = await POST(req as any)
113+
const json = await res.json()
114+
115+
expect(res.status).toBe(404)
116+
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
117+
expect(json).toEqual({
118+
error: 'Organization mutations are handled by application API routes.',
119+
})
120+
})
121+
122+
it('allows safe Better Auth organization session endpoints', async () => {
123+
const { NextResponse } = await import('next/server')
124+
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
125+
new NextResponse(JSON.stringify({ data: { ok: true } }), {
126+
headers: { 'content-type': 'application/json' },
127+
}) as any
128+
)
129+
130+
const req = createMockRequest(
131+
'POST',
132+
undefined,
133+
{},
134+
'http://localhost:3000/api/auth/organization/set-active'
135+
)
136+
137+
const res = await POST(req as any)
138+
const json = await res.json()
139+
140+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
141+
expect(json).toEqual({ data: { ok: true } })
142+
})
143+
})

apps/sim/app/api/auth/[...all]/route.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { isAuthDisabled } from '@/lib/core/config/feature-flags'
77
export const dynamic = 'force-dynamic'
88

99
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
10+
const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active'])
11+
12+
function isBlockedOrganizationMutationPath(path: string): boolean {
13+
return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path)
14+
}
1015

1116
export async function GET(request: NextRequest) {
1217
const url = new URL(request.url)
@@ -20,4 +25,16 @@ export async function GET(request: NextRequest) {
2025
return betterAuthGET(request)
2126
}
2227

23-
export const POST = betterAuthPOST
28+
export async function POST(request: NextRequest) {
29+
const url = new URL(request.url)
30+
const path = url.pathname.replace('/api/auth/', '')
31+
32+
if (isBlockedOrganizationMutationPath(path)) {
33+
return NextResponse.json(
34+
{ error: 'Organization mutations are handled by application API routes.' },
35+
{ status: 404 }
36+
)
37+
}
38+
39+
return betterAuthPOST(request)
40+
}

0 commit comments

Comments
 (0)