Skip to content
Merged
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
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@
npx sim-setup
```

Open [http://localhost:3000](http://localhost:3000)

### Desktop: [macOS](https://sim.ai/api/desktop/update/download)

<a href="https://sim.ai/api/desktop/update/download" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Download-macOS-3B3B3B?logo=apple&logoColor=white&labelColor=1A1A1A" alt="Download Sim for macOS"></a>
Expand Down
22 changes: 12 additions & 10 deletions apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import { resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({
mockIsOrganizationAdminOrOwner: vi.fn<() => Promise<boolean>>(),
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
}))
const { mockIsOrganizationAdminOrOwner, mockIsOrganizationPermissionRegimeActive } = vi.hoisted(
() => ({
mockIsOrganizationAdminOrOwner: vi.fn<() => Promise<boolean>>(),
mockIsOrganizationPermissionRegimeActive: vi.fn<() => Promise<boolean>>(),
})
)

vi.mock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
vi.mock('@/lib/permission-groups/resolve.server', () => ({
isOrganizationPermissionRegimeActive: mockIsOrganizationPermissionRegimeActive,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
Expand All @@ -29,20 +31,20 @@ describe('authorizeOrgAccessControl', () => {

it('returns a 403 when the user is not an organization admin/owner', async () => {
mockIsOrganizationAdminOrOwner.mockResolvedValue(false)
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true)

const response = await authorizeOrgAccessControl('user-1', 'org-1')

expect(response).not.toBeNull()
expect(response?.status).toBe(403)
await expect(response?.json()).resolves.toEqual({ error: 'Admin permissions required' })
// Entitlement is only checked after the admin gate passes.
expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled()
expect(mockIsOrganizationPermissionRegimeActive).not.toHaveBeenCalled()
})

it('returns a 403 when the organization is not on an enterprise plan', async () => {
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
mockIsOrganizationPermissionRegimeActive.mockResolvedValue(false)

const response = await authorizeOrgAccessControl('user-1', 'org-1')

Expand All @@ -54,7 +56,7 @@ describe('authorizeOrgAccessControl', () => {

it('returns null when the user is an admin and the org is entitled', async () => {
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true)

const response = await authorizeOrgAccessControl('user-1', 'org-1')

Expand Down
13 changes: 6 additions & 7 deletions apps/sim/app/api/organizations/[id]/permission-groups/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { db } from '@sim/db'
import { permissionGroup, permissionGroupWorkspace, workspace } from '@sim/db/schema'
import { and, asc, eq, inArray } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import type { DbOrTx } from '@/lib/db/types'
import type {
AllMembersConflict,
ScopeConflict,
} from '@/lib/permission-groups/application/group-membership'
import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server'
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'

/** A workspace reference (id + display name). */
Expand All @@ -32,13 +32,12 @@ export async function authorizeOrgAccessControl(
}

/**
* The feature gate, deliberately, not the governance reader: the Access Control settings page is
* gated on the same plan check, so reading governance here would open the API for a past-due
* organization whose page still 404s. Restrictions keep applying through a dunning window —
* that is what the governance reader is for — but managing them follows the page.
* The active permission regime, which is what the Access Control page now reads too: an
* organization whose restrictions still apply has to be able to see and loosen them, and a
* deployment with Access Control switched off governs nobody, so neither should manage anything.
*/
const entitled = await isOrganizationOnEnterprisePlan(organizationId)
if (!entitled) {
const governed = await isOrganizationPermissionRegimeActive(organizationId)
if (!governed) {
return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 })
}

Expand Down
18 changes: 17 additions & 1 deletion apps/sim/app/o/[organizationId]/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
const enterprise: OrganizationSettingsFeatures = {
billingEnabled: true,
hasEnterprisePlan: true,
governanceActive: true,
hosted: true,
selfHosted: {},
}
Expand Down Expand Up @@ -46,12 +47,27 @@ describe('organization settings navigation', () => {
expect(
organizationSettingsNavigation(
true,
{ ...enterprise, hasEnterprisePlan: false },
{ ...enterprise, hasEnterprisePlan: false, governanceActive: false },
available
).map(({ id }) => id)
).toEqual(['billing', 'members', 'recently-deleted', 'search-mcp'])
})

/**
* A failing payment closes the plan gate while the organization's permission groups keep
* applying, so the page that edits them has to stay listed — otherwise its members are governed
* by rules nobody can reach until the invoice clears.
*/
it('keeps Access Control listed while the organization is still governed', () => {
expect(
organizationSettingsNavigation(
true,
{ ...enterprise, hasEnterprisePlan: false, governanceActive: true },
available
).map(({ id }) => id)
).toEqual(['billing', 'members', 'recently-deleted', 'access-control', 'search-mcp'])
})

it('honors individual self-hosted feature flags and hides billing when disabled', () => {
expect(
organizationSettingsNavigation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { ORGANIZATION_SETTINGS_GROUPS } from '@/components/settings/navigation'
import { SettingsSidebar } from '@/components/settings/settings-sidebar'
import { isApiClientError } from '@/lib/api/client/errors'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils'
import {
hasPaidSubscriptionStatus,
hasUsableSubscriptionAccess,
} from '@/lib/billing/subscriptions/utils'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import {
Expand Down Expand Up @@ -42,6 +45,16 @@ export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarPr
isEnterprise(summary.data.subscriptionPlan) &&
hasUsableSubscriptionAccess(summary.data.subscriptionStatus, summary.data.billingBlocked)
: settingsFeatures.hasEnterprisePlan,
/**
* Refreshed from the same summary, or the item the plan gate just hid would reappear only on
* reload. Governance keeps its own rule — an entitled status, block state ignored — because a
* failing payment does not stop the organization's permission groups from applying.
*/
governanceActive:
refreshPlan && summary
? isEnterprise(summary.data.subscriptionPlan) &&
hasPaidSubscriptionStatus(summary.data.subscriptionStatus)
: settingsFeatures.governanceActive,
}

const routes = organizationRoutes(organization.id)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/components/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ describe('settings navigation boundaries', () => {
).toEqual({
billingEnabled: false,
hasEnterprisePlan: true,
governanceActive: true,
hosted: false,
selfHosted: {
'connected-accounts': true,
Expand Down Expand Up @@ -492,6 +493,7 @@ describe('settings navigation boundaries', () => {
const hostedFree = {
billingEnabled: true,
hasEnterprisePlan: false,
governanceActive: false,
hosted: true,
selfHosted: {},
}
Expand Down
17 changes: 16 additions & 1 deletion apps/sim/components/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,18 +1014,27 @@ export function resolveOrganizationSectionAccess({
export interface OrganizationSettingsFeatures {
billingEnabled: boolean
hasEnterprisePlan: boolean
/**
* Whether the organization's permission-group regime is in force, which outlives the plan gate
* through a failing payment — see `isOrganizationGovernanceActive`. Only Access Control reads
* it, because only that section edits something that keeps applying while the gate is closed.
*/
governanceActive: boolean
hosted: boolean
selfHosted: Partial<Record<OrganizationSettingsSection, boolean>>
}

export function getOrganizationSettingsFeatures(
hasEnterprisePlan: boolean,
deployment: DeploymentShape
deployment: DeploymentShape,
/** Defaults to the plan gate, so a caller with no reason to distinguish the two keeps its behavior. */
governanceActive: boolean = hasEnterprisePlan
Comment thread
waleedlatif1 marked this conversation as resolved.
): OrganizationSettingsFeatures {
const { features } = deployment
return {
billingEnabled: deployment.billingEnabled,
hasEnterprisePlan,
governanceActive,
hosted: deployment.hosted,
selfHosted: {
'connected-accounts': true,
Expand Down Expand Up @@ -1055,6 +1064,12 @@ export function isOrganizationSettingsSectionAvailable(
/* Sim Search itself is enterprise on the hosted product; self-hosted gates it by flag, not by section. */
if (section === 'integrations' || section === 'search-slack')
return !features.hosted || features.hasEnterprisePlan
/**
* Access Control follows governance rather than the plan gate: its restrictions keep applying
* through a failing payment, so hiding the page that edits them would leave an organization
* governed by rules it cannot see or loosen until the invoice clears.
*/
if (section === 'access-control' && features.hosted) return features.governanceActive
Comment thread
waleedlatif1 marked this conversation as resolved.
if (features.hosted) return features.hasEnterprisePlan
return features.selfHosted[section] ?? false
}
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/organizations/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({

vi.mock('@/lib/permission-groups/resolve.server', () => ({
getUserPermissionConfigForOrganization: mockPermissionConfig,
/** The nav lists Access Control on the regime; these tests drive it from the plan knob. */
isOrganizationPermissionRegimeActive: mockEnterprisePlan,
}))
vi.mock('@/lib/billing/core/subscription', () => ({
isOrganizationOnEnterprisePlan: mockEnterprisePlan,
Expand Down
54 changes: 39 additions & 15 deletions apps/sim/lib/organizations/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
} from '@/lib/knowledge/access/availability'
import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access'
import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions'
import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server'
import {
getUserPermissionConfigForOrganization,
isOrganizationPermissionRegimeActive,
} from '@/lib/permission-groups/resolve.server'

export interface OrganizationSurfaceOrganization {
id: string
Expand Down Expand Up @@ -77,19 +80,36 @@ async function resolveOrganizationSurfaceContext(
if (!row) return null

const deployment = getDeploymentShape()
const [config, [{ memberCount }], connectedAccountsAvailable, searchAccess, hasEnterprisePlan] =
await Promise.all([
getUserPermissionConfigForOrganization(organizationId),
db
.select({ memberCount: count() })
.from(member)
.where(eq(member.organizationId, organizationId)),
isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }),
resolveKnowledgeAccessAvailability({ organizationId }),
deployment.hosted && access.isAdmin
? isOrganizationOnEnterprisePlan(organizationId)
: Promise.resolve(false),
])
const [
config,
[{ memberCount }],
connectedAccountsAvailable,
searchAccess,
hasEnterprisePlan,
governanceActive,
] = await Promise.all([
getUserPermissionConfigForOrganization(organizationId),
db
.select({ memberCount: count() })
.from(member)
.where(eq(member.organizationId, organizationId)),
isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }),
resolveKnowledgeAccessAvailability({ organizationId }),
deployment.hosted && access.isAdmin
? isOrganizationOnEnterprisePlan(organizationId)
: Promise.resolve(false),
/**
* Access Control stays listed while a payment is failing, because its rules still apply.
*
* Resolved rather than rejected on a read failure: this value only decides whether a nav item
* is drawn, and it is shared by every organization page — letting it throw would take home,
* chat and search down with the billing table. The page and the management API read the same
* regime and still fail closed, so a listed item cannot be used to reach anything.
*/
deployment.hosted && access.isAdmin
? isOrganizationPermissionRegimeActive(organizationId).catch(() => false)
: Promise.resolve(false),
Comment thread
waleedlatif1 marked this conversation as resolved.
])
return {
organization: {
id: row.id,
Expand All @@ -112,7 +132,11 @@ async function resolveOrganizationSurfaceContext(
},
connectedAccountsAvailable,
searchAccess,
settingsFeatures: getOrganizationSettingsFeatures(hasEnterprisePlan, deployment),
settingsFeatures: getOrganizationSettingsFeatures(
hasEnterprisePlan,
deployment,
governanceActive
),
deployment,
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
canOpen: vi.fn(),
enterprise: vi.fn(),
governance: vi.fn(),
groups: vi.fn(),
search: vi.fn(),
}))
Expand All @@ -21,6 +22,7 @@ vi.mock('@/lib/organizations/settings-access', () => ({
}))
vi.mock('@/lib/billing/core/subscription', () => ({
isOrganizationOnEnterprisePlan: mocks.enterprise,
isOrganizationGovernanceActive: mocks.governance,
}))

import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
Expand All @@ -31,6 +33,7 @@ describe('organization settings authorization', () => {
setEnvFlags({ isHosted: true, isBillingEnabled: true })
mocks.canOpen.mockResolvedValue(true)
mocks.enterprise.mockResolvedValue(true)
mocks.governance.mockResolvedValue(true)
mocks.groups.mockResolvedValue(true)
mocks.search.mockResolvedValue(true)
})
Expand All @@ -57,6 +60,49 @@ describe('organization settings authorization', () => {
}
)

/**
* Access Control configures restrictions that keep applying while a payment is failing, so the
* page that edits them has to stay reachable — otherwise an organization is governed by rules
* nobody can see or loosen until the invoice clears.
*/
it('opens Access Control for an organization still being governed', async () => {
mocks.enterprise.mockResolvedValue(false)
mocks.governance.mockResolvedValue(true)

await expect(
authorizeOrganizationSettingsSection({
organizationId: 'target',
userId: 'viewer',
section: 'access-control',
})
).resolves.toBe(true)
})

it('closes Access Control once nothing governs the organization', async () => {
mocks.enterprise.mockResolvedValue(false)
mocks.governance.mockResolvedValue(false)

await expect(
authorizeOrganizationSettingsSection({
organizationId: 'target',
userId: 'viewer',
section: 'access-control',
})
).resolves.toBe(false)
})

/** Every other section keeps reading the plan gate, and pays no extra lookup for this one. */
it('reads governance for no section but Access Control', async () => {
await authorizeOrganizationSettingsSection({
organizationId: 'target',
userId: 'viewer',
section: 'audit-logs',
})

expect(mocks.governance).not.toHaveBeenCalled()
expect(mocks.enterprise).toHaveBeenCalledWith('target')
})

it.each([
{ groups: false, search: false, connectedAccounts: false, integrations: false },
{ groups: true, search: false, connectedAccounts: true, integrations: false },
Expand Down
Loading
Loading