Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/docs/content/docs/en/execution/costs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,17 @@ By default, your usage is capped at the credits included in your plan. To allow

## Plan Limits

### Workspaces

| Plan | Personal Workspaces | Shared (Organization) Workspaces |
|------|---------------------|----------------------------------|
| **Free** | 1 | — |
| **Pro** | Up to 3 | — |
| **Max** | Up to 10 | — |
| **Team / Enterprise** | Unlimited | Unlimited |

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.

### Rate Limits

| Plan | Sync (req/min) | Async (req/min) |
Expand Down
52 changes: 45 additions & 7 deletions apps/docs/content/docs/en/permissions/roles-and-permissions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,31 @@
title: "Roles and Permissions"
---

import { Callout } from 'fumadocs-ui/components/callout'
import { Video } from '@/components/ui/video'

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.

## Workspaces and Organizations

Sim has two kinds of workspaces:

- **Personal workspaces** live under your individual account. The number you can create depends on your plan.
- **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.

### Workspace Limits by Plan

| Plan | Personal Workspaces | Shared Workspaces |
|------|---------------------|-------------------|
| **Free** | 1 | — |
| **Pro** | Up to 3 | — |
| **Max** | Up to 10 | — |
| **Team / Enterprise** | Unlimited | Unlimited (seat-gated invites) |

<Callout type="info">
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.
</Callout>

## How to Invite Someone to a Workspace

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

<Callout type="info">
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.
</Callout>

---

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

## Organization Roles

When inviting someone to your organization, you can assign one of two roles:
An organization has three roles: **Owner**, **Admin**, and **Member**.

### Organization Owner
**What they can do:**
- Everything an Admin can do
- Transfer organization ownership to another user
- Only one Owner exists per organization

### Organization Admin
**What they can do:**
- Invite and remove team members from the organization
- Create new workspaces
- Manage billing and subscription settings
- Access all workspaces within the organization
- Create new shared workspaces under the organization
- Manage billing, seat count, and subscription settings
- Access all shared workspaces within the organization as a workspace Admin
- Promote members to Admin or demote Admins to Member

<Callout type="info">
Owners and Admins have the same day-to-day permissions. The only action reserved for the Owner is transferring ownership.
</Callout>

### Organization Member
**What they can do:**
- Access workspaces they've been specifically invited to
- Access shared workspaces they've been specifically invited to
- View the list of organization members
- Cannot invite new people or manage organization settings
- Cannot invite new people, create shared workspaces, or manage organization settings

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

<FAQ items={[
{ 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." },
{ 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." },
{ 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." },
{ 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." },
{ 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." },
{ 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." },
{ 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." },
Expand Down
14 changes: 10 additions & 4 deletions apps/sim/app/(landing)/components/pricing/pricing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const PRICING_TIERS: PricingTier[] = [
'1,000 credits (trial)',
'5GB file storage',
'3 tables · 1,000 rows each',
'1 personal workspace',
'5 min execution limit',
'7-day log retention',
'CLI/SDK/MCP Access',
Expand All @@ -56,6 +57,7 @@ const PRICING_TIERS: PricingTier[] = [
'6,000 credits/mo · +50/day',
'50GB file storage',
'25 tables · 5,000 rows each',
'Up to 3 personal workspaces',
'50 min execution · 150 runs/min',
'Unlimited log retention',
'CLI/SDK/MCP Access',
Expand All @@ -73,6 +75,7 @@ const PRICING_TIERS: PricingTier[] = [
'25,000 credits/mo · +200/day',
'500GB file storage',
'25 tables · 5,000 rows each',
'Up to 10 personal workspaces',
'50 min execution · 300 runs/min',
'Unlimited log retention',
'CLI/SDK/MCP Access',
Expand All @@ -89,6 +92,7 @@ const PRICING_TIERS: PricingTier[] = [
'Custom credits & infra limits',
'Custom file storage',
'10,000 tables · 1M rows each',
'Unlimited shared workspaces',
'Custom execution limits',
'Unlimited log retention',
'SSO & SCIM · SOC2',
Expand Down Expand Up @@ -264,10 +268,12 @@ export default function Pricing() {
Pricing
</h2>
<p className='sr-only'>
Sim pricing: Community plan is free with 1,000 credits and 5GB storage. Pro plan is $25
per month with 6,000 credits and 50GB storage. Max plan is $100 per month with 25,000
credits and 500GB storage. Enterprise pricing is custom with SSO, SCIM, SOC2 compliance,
self-hosting, and dedicated support. All plans include CLI, SDK, and MCP access.
Sim pricing: Community plan is free with 1,000 credits, 5GB storage, and 1 personal
workspace. Pro plan is $25 per month with 6,000 credits, 50GB storage, and up to 3
personal workspaces. Max plan is $100 per month with 25,000 credits, 500GB storage, and
up to 10 personal workspaces. Enterprise pricing is custom with unlimited shared
workspaces, SSO, SCIM, SOC2 compliance, self-hosting, and dedicated support. All plans
include CLI, SDK, and MCP access.
</p>
</div>

Expand Down
64 changes: 57 additions & 7 deletions apps/sim/app/_shell/providers/session-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type React from 'react'
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/auth/auth-client'
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
Expand Down Expand Up @@ -34,6 +35,8 @@ export type SessionHookResult = {

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

const logger = createLogger('SessionProvider')

export function SessionProvider({ children }: { children: React.ReactNode }) {
const [data, setData] = useState<AppSession>(null)
const [isPending, setIsPending] = useState(true)
Expand All @@ -49,14 +52,18 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
: await client.getSession()
const session = extractSessionDataFromAuthClientResult(res) as AppSession
setData(session)
return session
} catch (e) {
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
return null
} finally {
setIsPending(false)
}
}, [])

useEffect(() => {
let isCancelled = false

// Check if user was redirected after plan upgrade
const params = new URLSearchParams(window.location.search)
const wasUpgraded = params.get('upgraded') === 'true'
Expand All @@ -69,12 +76,51 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
window.history.replaceState({}, '', newUrl)
}

loadSession(wasUpgraded).then(() => {
if (wasUpgraded) {
queryClient.invalidateQueries({ queryKey: ['organizations'] })
queryClient.invalidateQueries({ queryKey: ['subscription'] })
const initializeSession = async () => {
const session = await loadSession(wasUpgraded)

if (!wasUpgraded || isCancelled) {
return
}
})

queryClient.invalidateQueries({ queryKey: ['organizations'] })
queryClient.invalidateQueries({ queryKey: ['subscription'] })

const activeOrganizationId = session?.session?.activeOrganizationId ?? null
if (activeOrganizationId) {
return
}

try {
const response = await fetch('/api/organizations')
if (!response.ok) {
return
}

const orgData = (await response.json()) as {
organizations?: Array<{ id: string }>
}
const organizationId = orgData.organizations?.[0]?.id

if (!organizationId || isCancelled) {
return
}

await client.organization.setActive({ organizationId })

if (!isCancelled) {
await loadSession(true)
}
} catch (error) {
logger.warn('Failed to activate organization after subscription upgrade', { error })
}
}

void initializeSession()

return () => {
isCancelled = true
}
}, [loadSession, queryClient])

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

const refetch = useCallback(async () => {
await loadSession()
}, [loadSession])

const value = useMemo<SessionHookResult>(
() => ({ data, isPending, error, refetch: loadSession }),
[data, isPending, error, loadSession]
() => ({ data, isPending, error, refetch }),
[data, isPending, error, refetch]
)

return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
Expand Down
48 changes: 47 additions & 1 deletion apps/sim/app/api/auth/[...all]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
},
}))

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

describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
beforeEach(() => {
Expand Down Expand Up @@ -95,3 +95,49 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
expect(json).toEqual({ data: { ok: true } })
})
})

describe('auth catch-all route organization mutations', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('blocks Better Auth organization mutation endpoints that bypass app lifecycle rules', async () => {
const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/auth/organization/create'
)

const res = await POST(req as any)
const json = await res.json()

expect(res.status).toBe(404)
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
expect(json).toEqual({
error: 'Organization mutations are handled by application API routes.',
})
})

it('allows safe Better Auth organization session endpoints', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
new NextResponse(JSON.stringify({ data: { ok: true } }), {
headers: { 'content-type': 'application/json' },
}) as any
)

const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/auth/organization/set-active'
)

const res = await POST(req as any)
const json = await res.json()

expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
expect(json).toEqual({ data: { ok: true } })
})
})
19 changes: 18 additions & 1 deletion apps/sim/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { isAuthDisabled } from '@/lib/core/config/feature-flags'
export const dynamic = 'force-dynamic'

const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active'])

function isBlockedOrganizationMutationPath(path: string): boolean {
return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path)
}

export async function GET(request: NextRequest) {
const url = new URL(request.url)
Expand All @@ -20,4 +25,16 @@ export async function GET(request: NextRequest) {
return betterAuthGET(request)
}

export const POST = betterAuthPOST
export async function POST(request: NextRequest) {
const url = new URL(request.url)
const path = url.pathname.replace('/api/auth/', '')

if (isBlockedOrganizationMutationPath(path)) {
return NextResponse.json(
{ error: 'Organization mutations are handled by application API routes.' },
{ status: 404 }
)
}

return betterAuthPOST(request)
}
Loading
Loading