Skip to content

Commit 212a3a9

Browse files
authored
fix(access-control): keep the settings page open while an organization is governed (#7890)
* fix(access-control): keep the settings page open while an organization is governed Permission groups keep applying through a failing payment, but the page that edits them was hidden with the rest of the Enterprise sections — so an organization could be governed by rules nobody could see or loosen until the invoice cleared. Access Control now follows the governance reader rather than the plan gate, on the page, in the navigation, and at the management API, which had already been left behind the plan gate to match the page. Also drops a stray "Open localhost" line from the README's self-hosted quick start; the walkthrough below it already says where to look. * fix(access-control): read the active permission regime, not the raw plan - Resolve the navigation flag rather than reject it: the organization surface is shared by every page, so a failed billing read would have taken home, chat and search down with the settings sidebar - Read the regime helper everywhere instead of the governance reader, so a deployment with Access Control switched off manages nothing - Give the workspace-scoped page the same treatment as the organization one, which was still plan-gated - Read one lookup per section rather than both, since Access Control's availability never consults the plan - Refresh the navigation flag from the billing summary alongside the plan it sits next to, so the item cannot linger after billing changes
1 parent 95b5b76 commit 212a3a9

13 files changed

Lines changed: 205 additions & 45 deletions

File tree

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@
3030
npx sim-setup
3131
```
3232

33-
Open [http://localhost:3000](http://localhost:3000)
34-
3533
### Desktop: [macOS](https://sim.ai/api/desktop/update/download)
3634

3735
<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>

apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
import { resetDbChainMock } from '@sim/testing'
55
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({
8-
mockIsOrganizationAdminOrOwner: vi.fn<() => Promise<boolean>>(),
9-
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
10-
}))
7+
const { mockIsOrganizationAdminOrOwner, mockIsOrganizationPermissionRegimeActive } = vi.hoisted(
8+
() => ({
9+
mockIsOrganizationAdminOrOwner: vi.fn<() => Promise<boolean>>(),
10+
mockIsOrganizationPermissionRegimeActive: vi.fn<() => Promise<boolean>>(),
11+
})
12+
)
1113

12-
vi.mock('@/lib/billing', () => ({
13-
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
14+
vi.mock('@/lib/permission-groups/resolve.server', () => ({
15+
isOrganizationPermissionRegimeActive: mockIsOrganizationPermissionRegimeActive,
1416
}))
1517

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

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

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

3638
expect(response).not.toBeNull()
3739
expect(response?.status).toBe(403)
3840
await expect(response?.json()).resolves.toEqual({ error: 'Admin permissions required' })
3941
// Entitlement is only checked after the admin gate passes.
40-
expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled()
42+
expect(mockIsOrganizationPermissionRegimeActive).not.toHaveBeenCalled()
4143
})
4244

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

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

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

5557
it('returns null when the user is an admin and the org is entitled', async () => {
5658
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
57-
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
59+
mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true)
5860

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

apps/sim/app/api/organizations/[id]/permission-groups/utils.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { db } from '@sim/db'
22
import { permissionGroup, permissionGroupWorkspace, workspace } from '@sim/db/schema'
33
import { and, asc, eq, inArray } from 'drizzle-orm'
44
import { NextResponse } from 'next/server'
5-
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
65
import type { DbOrTx } from '@/lib/db/types'
76
import type {
87
AllMembersConflict,
98
ScopeConflict,
109
} from '@/lib/permission-groups/application/group-membership'
10+
import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server'
1111
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
1212

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

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

apps/sim/app/o/[organizationId]/settings/navigation.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
const enterprise: OrganizationSettingsFeatures = {
1919
billingEnabled: true,
2020
hasEnterprisePlan: true,
21+
governanceActive: true,
2122
hosted: true,
2223
selfHosted: {},
2324
}
@@ -46,12 +47,27 @@ describe('organization settings navigation', () => {
4647
expect(
4748
organizationSettingsNavigation(
4849
true,
49-
{ ...enterprise, hasEnterprisePlan: false },
50+
{ ...enterprise, hasEnterprisePlan: false, governanceActive: false },
5051
available
5152
).map(({ id }) => id)
5253
).toEqual(['billing', 'members', 'recently-deleted', 'search-mcp'])
5354
})
5455

56+
/**
57+
* A failing payment closes the plan gate while the organization's permission groups keep
58+
* applying, so the page that edits them has to stay listed — otherwise its members are governed
59+
* by rules nobody can reach until the invoice clears.
60+
*/
61+
it('keeps Access Control listed while the organization is still governed', () => {
62+
expect(
63+
organizationSettingsNavigation(
64+
true,
65+
{ ...enterprise, hasEnterprisePlan: false, governanceActive: true },
66+
available
67+
).map(({ id }) => id)
68+
).toEqual(['billing', 'members', 'recently-deleted', 'access-control', 'search-mcp'])
69+
})
70+
5571
it('honors individual self-hosted feature flags and hides billing when disabled', () => {
5672
expect(
5773
organizationSettingsNavigation(

apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { ORGANIZATION_SETTINGS_GROUPS } from '@/components/settings/navigation'
66
import { SettingsSidebar } from '@/components/settings/settings-sidebar'
77
import { isApiClientError } from '@/lib/api/client/errors'
88
import { isEnterprise } from '@/lib/billing/plan-helpers'
9-
import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils'
9+
import {
10+
hasPaidSubscriptionStatus,
11+
hasUsableSubscriptionAccess,
12+
} from '@/lib/billing/subscriptions/utils'
1013
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
1114
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
1215
import {
@@ -42,6 +45,16 @@ export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarPr
4245
isEnterprise(summary.data.subscriptionPlan) &&
4346
hasUsableSubscriptionAccess(summary.data.subscriptionStatus, summary.data.billingBlocked)
4447
: settingsFeatures.hasEnterprisePlan,
48+
/**
49+
* Refreshed from the same summary, or the item the plan gate just hid would reappear only on
50+
* reload. Governance keeps its own rule — an entitled status, block state ignored — because a
51+
* failing payment does not stop the organization's permission groups from applying.
52+
*/
53+
governanceActive:
54+
refreshPlan && summary
55+
? isEnterprise(summary.data.subscriptionPlan) &&
56+
hasPaidSubscriptionStatus(summary.data.subscriptionStatus)
57+
: settingsFeatures.governanceActive,
4558
}
4659

4760
const routes = organizationRoutes(organization.id)

apps/sim/components/settings/navigation.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ describe('settings navigation boundaries', () => {
231231
).toEqual({
232232
billingEnabled: false,
233233
hasEnterprisePlan: true,
234+
governanceActive: true,
234235
hosted: false,
235236
selfHosted: {
236237
'connected-accounts': true,
@@ -492,6 +493,7 @@ describe('settings navigation boundaries', () => {
492493
const hostedFree = {
493494
billingEnabled: true,
494495
hasEnterprisePlan: false,
496+
governanceActive: false,
495497
hosted: true,
496498
selfHosted: {},
497499
}

apps/sim/components/settings/navigation.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1014,18 +1014,27 @@ export function resolveOrganizationSectionAccess({
10141014
export interface OrganizationSettingsFeatures {
10151015
billingEnabled: boolean
10161016
hasEnterprisePlan: boolean
1017+
/**
1018+
* Whether the organization's permission-group regime is in force, which outlives the plan gate
1019+
* through a failing payment — see `isOrganizationGovernanceActive`. Only Access Control reads
1020+
* it, because only that section edits something that keeps applying while the gate is closed.
1021+
*/
1022+
governanceActive: boolean
10171023
hosted: boolean
10181024
selfHosted: Partial<Record<OrganizationSettingsSection, boolean>>
10191025
}
10201026

10211027
export function getOrganizationSettingsFeatures(
10221028
hasEnterprisePlan: boolean,
1023-
deployment: DeploymentShape
1029+
deployment: DeploymentShape,
1030+
/** Defaults to the plan gate, so a caller with no reason to distinguish the two keeps its behavior. */
1031+
governanceActive: boolean = hasEnterprisePlan
10241032
): OrganizationSettingsFeatures {
10251033
const { features } = deployment
10261034
return {
10271035
billingEnabled: deployment.billingEnabled,
10281036
hasEnterprisePlan,
1037+
governanceActive,
10291038
hosted: deployment.hosted,
10301039
selfHosted: {
10311040
'connected-accounts': true,
@@ -1055,6 +1064,12 @@ export function isOrganizationSettingsSectionAvailable(
10551064
/* Sim Search itself is enterprise on the hosted product; self-hosted gates it by flag, not by section. */
10561065
if (section === 'integrations' || section === 'search-slack')
10571066
return !features.hosted || features.hasEnterprisePlan
1067+
/**
1068+
* Access Control follows governance rather than the plan gate: its restrictions keep applying
1069+
* through a failing payment, so hiding the page that edits them would leave an organization
1070+
* governed by rules it cannot see or loosen until the invoice clears.
1071+
*/
1072+
if (section === 'access-control' && features.hosted) return features.governanceActive
10581073
if (features.hosted) return features.hasEnterprisePlan
10591074
return features.selfHosted[section] ?? false
10601075
}

apps/sim/lib/organizations/surface.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({
1616

1717
vi.mock('@/lib/permission-groups/resolve.server', () => ({
1818
getUserPermissionConfigForOrganization: mockPermissionConfig,
19+
/** The nav lists Access Control on the regime; these tests drive it from the plan knob. */
20+
isOrganizationPermissionRegimeActive: mockEnterprisePlan,
1921
}))
2022
vi.mock('@/lib/billing/core/subscription', () => ({
2123
isOrganizationOnEnterprisePlan: mockEnterprisePlan,

apps/sim/lib/organizations/surface.ts

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import {
1818
} from '@/lib/knowledge/access/availability'
1919
import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access'
2020
import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions'
21-
import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server'
21+
import {
22+
getUserPermissionConfigForOrganization,
23+
isOrganizationPermissionRegimeActive,
24+
} from '@/lib/permission-groups/resolve.server'
2225

2326
export interface OrganizationSurfaceOrganization {
2427
id: string
@@ -77,19 +80,36 @@ async function resolveOrganizationSurfaceContext(
7780
if (!row) return null
7881

7982
const deployment = getDeploymentShape()
80-
const [config, [{ memberCount }], connectedAccountsAvailable, searchAccess, hasEnterprisePlan] =
81-
await Promise.all([
82-
getUserPermissionConfigForOrganization(organizationId),
83-
db
84-
.select({ memberCount: count() })
85-
.from(member)
86-
.where(eq(member.organizationId, organizationId)),
87-
isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }),
88-
resolveKnowledgeAccessAvailability({ organizationId }),
89-
deployment.hosted && access.isAdmin
90-
? isOrganizationOnEnterprisePlan(organizationId)
91-
: Promise.resolve(false),
92-
])
83+
const [
84+
config,
85+
[{ memberCount }],
86+
connectedAccountsAvailable,
87+
searchAccess,
88+
hasEnterprisePlan,
89+
governanceActive,
90+
] = await Promise.all([
91+
getUserPermissionConfigForOrganization(organizationId),
92+
db
93+
.select({ memberCount: count() })
94+
.from(member)
95+
.where(eq(member.organizationId, organizationId)),
96+
isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }),
97+
resolveKnowledgeAccessAvailability({ organizationId }),
98+
deployment.hosted && access.isAdmin
99+
? isOrganizationOnEnterprisePlan(organizationId)
100+
: Promise.resolve(false),
101+
/**
102+
* Access Control stays listed while a payment is failing, because its rules still apply.
103+
*
104+
* Resolved rather than rejected on a read failure: this value only decides whether a nav item
105+
* is drawn, and it is shared by every organization page — letting it throw would take home,
106+
* chat and search down with the billing table. The page and the management API read the same
107+
* regime and still fail closed, so a listed item cannot be used to reach anything.
108+
*/
109+
deployment.hosted && access.isAdmin
110+
? isOrganizationPermissionRegimeActive(organizationId).catch(() => false)
111+
: Promise.resolve(false),
112+
])
93113
return {
94114
organization: {
95115
id: row.id,
@@ -112,7 +132,11 @@ async function resolveOrganizationSurfaceContext(
112132
},
113133
connectedAccountsAvailable,
114134
searchAccess,
115-
settingsFeatures: getOrganizationSettingsFeatures(hasEnterprisePlan, deployment),
135+
settingsFeatures: getOrganizationSettingsFeatures(
136+
hasEnterprisePlan,
137+
deployment,
138+
governanceActive
139+
),
116140
deployment,
117141
}
118142
}

apps/sim/lib/settings/application/organization-section-access.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
const mocks = vi.hoisted(() => ({
88
canOpen: vi.fn(),
99
enterprise: vi.fn(),
10+
governance: vi.fn(),
1011
groups: vi.fn(),
1112
search: vi.fn(),
1213
}))
@@ -21,6 +22,7 @@ vi.mock('@/lib/organizations/settings-access', () => ({
2122
}))
2223
vi.mock('@/lib/billing/core/subscription', () => ({
2324
isOrganizationOnEnterprisePlan: mocks.enterprise,
25+
isOrganizationGovernanceActive: mocks.governance,
2426
}))
2527

2628
import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
@@ -31,6 +33,7 @@ describe('organization settings authorization', () => {
3133
setEnvFlags({ isHosted: true, isBillingEnabled: true })
3234
mocks.canOpen.mockResolvedValue(true)
3335
mocks.enterprise.mockResolvedValue(true)
36+
mocks.governance.mockResolvedValue(true)
3437
mocks.groups.mockResolvedValue(true)
3538
mocks.search.mockResolvedValue(true)
3639
})
@@ -57,6 +60,49 @@ describe('organization settings authorization', () => {
5760
}
5861
)
5962

63+
/**
64+
* Access Control configures restrictions that keep applying while a payment is failing, so the
65+
* page that edits them has to stay reachable — otherwise an organization is governed by rules
66+
* nobody can see or loosen until the invoice clears.
67+
*/
68+
it('opens Access Control for an organization still being governed', async () => {
69+
mocks.enterprise.mockResolvedValue(false)
70+
mocks.governance.mockResolvedValue(true)
71+
72+
await expect(
73+
authorizeOrganizationSettingsSection({
74+
organizationId: 'target',
75+
userId: 'viewer',
76+
section: 'access-control',
77+
})
78+
).resolves.toBe(true)
79+
})
80+
81+
it('closes Access Control once nothing governs the organization', async () => {
82+
mocks.enterprise.mockResolvedValue(false)
83+
mocks.governance.mockResolvedValue(false)
84+
85+
await expect(
86+
authorizeOrganizationSettingsSection({
87+
organizationId: 'target',
88+
userId: 'viewer',
89+
section: 'access-control',
90+
})
91+
).resolves.toBe(false)
92+
})
93+
94+
/** Every other section keeps reading the plan gate, and pays no extra lookup for this one. */
95+
it('reads governance for no section but Access Control', async () => {
96+
await authorizeOrganizationSettingsSection({
97+
organizationId: 'target',
98+
userId: 'viewer',
99+
section: 'audit-logs',
100+
})
101+
102+
expect(mocks.governance).not.toHaveBeenCalled()
103+
expect(mocks.enterprise).toHaveBeenCalledWith('target')
104+
})
105+
60106
it.each([
61107
{ groups: false, search: false, connectedAccounts: false, integrations: false },
62108
{ groups: true, search: false, connectedAccounts: true, integrations: false },

0 commit comments

Comments
 (0)