From 4d808e1229180db81fca84431eeb6ea7278d9554 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 16 Sep 2026 13:09:28 -0700 Subject: [PATCH 1/2] fix(access-control): keep the settings page open while an organization is governed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 - .../[id]/permission-groups/utils.test.ts | 14 +++--- .../[id]/permission-groups/utils.ts | 12 ++--- .../settings/navigation.test.ts | 18 ++++++- .../components/settings/navigation.test.ts | 2 + apps/sim/components/settings/navigation.ts | 17 ++++++- apps/sim/lib/organizations/surface.test.ts | 2 + apps/sim/lib/organizations/surface.ts | 47 +++++++++++++------ .../organization-section-access.test.ts | 46 ++++++++++++++++++ .../organization-section-access.ts | 23 +++++++-- 10 files changed, 145 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 78c103799e2..f25af677768 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,6 @@ npx sim-setup ``` -Open [http://localhost:3000](http://localhost:3000) - ### Desktop: [macOS](https://sim.ai/api/desktop/update/download) Download Sim for macOS diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index 62c0ddb18cc..2f368184796 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -4,13 +4,13 @@ import { resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({ +const { mockIsOrganizationAdminOrOwner, mockIsOrganizationGovernanceActive } = vi.hoisted(() => ({ mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), - mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), + mockIsOrganizationGovernanceActive: vi.fn<() => Promise>(), })) vi.mock('@/lib/billing', () => ({ - isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, + isOrganizationGovernanceActive: mockIsOrganizationGovernanceActive, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -29,7 +29,7 @@ describe('authorizeOrgAccessControl', () => { it('returns a 403 when the user is not an organization admin/owner', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(false) - mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + mockIsOrganizationGovernanceActive.mockResolvedValue(true) const response = await authorizeOrgAccessControl('user-1', 'org-1') @@ -37,12 +37,12 @@ describe('authorizeOrgAccessControl', () => { 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(mockIsOrganizationGovernanceActive).not.toHaveBeenCalled() }) it('returns a 403 when the organization is not on an enterprise plan', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(true) - mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + mockIsOrganizationGovernanceActive.mockResolvedValue(false) const response = await authorizeOrgAccessControl('user-1', 'org-1') @@ -54,7 +54,7 @@ describe('authorizeOrgAccessControl', () => { it('returns null when the user is an admin and the org is entitled', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(true) - mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + mockIsOrganizationGovernanceActive.mockResolvedValue(true) const response = await authorizeOrgAccessControl('user-1', 'org-1') diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts index cb19b17e163..d05d1163aa3 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts @@ -2,7 +2,7 @@ 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 { isOrganizationGovernanceActive } from '@/lib/billing' import type { DbOrTx } from '@/lib/db/types' import type { AllMembersConflict, @@ -32,13 +32,11 @@ 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. + * Governance, not the plan gate: an organization whose restrictions still apply has to be able + * to see and loosen them, so this matches what the Access Control page now allows. */ - const entitled = await isOrganizationOnEnterprisePlan(organizationId) - if (!entitled) { + const governed = await isOrganizationGovernanceActive(organizationId) + if (!governed) { return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 }) } diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts index 94ec37b962a..ba3a295e3e0 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts @@ -18,6 +18,7 @@ import { const enterprise: OrganizationSettingsFeatures = { billingEnabled: true, hasEnterprisePlan: true, + governanceActive: true, hosted: true, selfHosted: {}, } @@ -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( diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index e469f904048..eab988612ea 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -231,6 +231,7 @@ describe('settings navigation boundaries', () => { ).toEqual({ billingEnabled: false, hasEnterprisePlan: true, + governanceActive: true, hosted: false, selfHosted: { 'connected-accounts': true, @@ -492,6 +493,7 @@ describe('settings navigation boundaries', () => { const hostedFree = { billingEnabled: true, hasEnterprisePlan: false, + governanceActive: false, hosted: true, selfHosted: {}, } diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index e46615cf09f..c4919d321ba 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -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> } 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 ): OrganizationSettingsFeatures { const { features } = deployment return { billingEnabled: deployment.billingEnabled, hasEnterprisePlan, + governanceActive, hosted: deployment.hosted, selfHosted: { 'connected-accounts': true, @@ -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 if (features.hosted) return features.hasEnterprisePlan return features.selfHosted[section] ?? false } diff --git a/apps/sim/lib/organizations/surface.test.ts b/apps/sim/lib/organizations/surface.test.ts index 4a622a29d61..8b6658401d4 100644 --- a/apps/sim/lib/organizations/surface.test.ts +++ b/apps/sim/lib/organizations/surface.test.ts @@ -19,6 +19,8 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ })) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockEnterprisePlan, + /** The nav lists Access Control on governance; these tests drive both from one knob. */ + isOrganizationGovernanceActive: mockEnterprisePlan, })) vi.mock('@/lib/knowledge/access/availability', () => ({ resolveKnowledgeAccessAvailability: mockSearchAccess, diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts index ee2c3283c5a..66dddd0121c 100644 --- a/apps/sim/lib/organizations/surface.ts +++ b/apps/sim/lib/organizations/surface.ts @@ -8,7 +8,10 @@ import { } from '@/components/settings/navigation' import type { OrganizationRole } from '@/lib/api/contracts/primitives' import type { DeploymentShape } from '@/lib/api/contracts/workspaces' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { + isOrganizationGovernanceActive, + isOrganizationOnEnterprisePlan, +} from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { isInvitationsDisabled } from '@/lib/core/config/env-flags' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' @@ -77,19 +80,29 @@ 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. */ + deployment.hosted && access.isAdmin + ? isOrganizationGovernanceActive(organizationId) + : Promise.resolve(false), + ]) return { organization: { id: row.id, @@ -112,7 +125,11 @@ async function resolveOrganizationSurfaceContext( }, connectedAccountsAvailable, searchAccess, - settingsFeatures: getOrganizationSettingsFeatures(hasEnterprisePlan, deployment), + settingsFeatures: getOrganizationSettingsFeatures( + hasEnterprisePlan, + deployment, + governanceActive + ), deployment, } } diff --git a/apps/sim/lib/settings/application/organization-section-access.test.ts b/apps/sim/lib/settings/application/organization-section-access.test.ts index f425f2c3604..d14162a91bc 100644 --- a/apps/sim/lib/settings/application/organization-section-access.test.ts +++ b/apps/sim/lib/settings/application/organization-section-access.test.ts @@ -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(), })) @@ -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' @@ -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) }) @@ -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 }, diff --git a/apps/sim/lib/settings/application/organization-section-access.ts b/apps/sim/lib/settings/application/organization-section-access.ts index bf762ea9e8a..5ae9e16843e 100644 --- a/apps/sim/lib/settings/application/organization-section-access.ts +++ b/apps/sim/lib/settings/application/organization-section-access.ts @@ -3,7 +3,10 @@ import { isOrganizationSettingsSectionAvailable, type OrganizationSettingsSection, } from '@/components/settings/navigation' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { + isOrganizationGovernanceActive, + isOrganizationOnEnterprisePlan, +} from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' @@ -33,12 +36,22 @@ export async function authorizeOrganizationSettingsSection({ const deployment = getDeploymentShape() const needsEnterprisePlan = deployment.hosted && section !== 'members' && section !== 'billing' - const hasEnterprisePlan = needsEnterprisePlan - ? await isOrganizationOnEnterprisePlan(organizationId) - : false + /** + * Access Control is the one section whose availability follows governance rather than the plan + * gate, and it is the only one that reads it — so the extra lookup is scoped to that section + * instead of being paid on every settings page. + */ + const [hasEnterprisePlan, governanceActive] = needsEnterprisePlan + ? await Promise.all([ + isOrganizationOnEnterprisePlan(organizationId), + section === 'access-control' + ? isOrganizationGovernanceActive(organizationId) + : Promise.resolve(false), + ]) + : [false, false] return isOrganizationSettingsSectionAvailable( section, - getOrganizationSettingsFeatures(hasEnterprisePlan, deployment) + getOrganizationSettingsFeatures(hasEnterprisePlan, deployment, governanceActive) ) } From a60c56dae224d78c884361445987210b649a30bb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 16 Sep 2026 13:31:03 -0700 Subject: [PATCH 2/2] 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 --- .../[id]/permission-groups/utils.test.ts | 22 +++++++++------- .../[id]/permission-groups/utils.ts | 9 ++++--- .../organization-settings-sidebar.tsx | 15 ++++++++++- apps/sim/lib/organizations/surface.test.ts | 4 +-- apps/sim/lib/organizations/surface.ts | 21 ++++++++++----- .../organization-section-access.ts | 26 ++++++++----------- .../workspace-section-access.test.ts | 26 ++++++++++++++++++- .../application/workspace-section-access.ts | 16 +++++++++--- 8 files changed, 96 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index 2f368184796..c426e067815 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -4,13 +4,15 @@ import { resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsOrganizationAdminOrOwner, mockIsOrganizationGovernanceActive } = vi.hoisted(() => ({ - mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), - mockIsOrganizationGovernanceActive: vi.fn<() => Promise>(), -})) +const { mockIsOrganizationAdminOrOwner, mockIsOrganizationPermissionRegimeActive } = vi.hoisted( + () => ({ + mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), + mockIsOrganizationPermissionRegimeActive: vi.fn<() => Promise>(), + }) +) -vi.mock('@/lib/billing', () => ({ - isOrganizationGovernanceActive: mockIsOrganizationGovernanceActive, +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + isOrganizationPermissionRegimeActive: mockIsOrganizationPermissionRegimeActive, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -29,7 +31,7 @@ describe('authorizeOrgAccessControl', () => { it('returns a 403 when the user is not an organization admin/owner', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(false) - mockIsOrganizationGovernanceActive.mockResolvedValue(true) + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) const response = await authorizeOrgAccessControl('user-1', 'org-1') @@ -37,12 +39,12 @@ describe('authorizeOrgAccessControl', () => { 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(mockIsOrganizationGovernanceActive).not.toHaveBeenCalled() + expect(mockIsOrganizationPermissionRegimeActive).not.toHaveBeenCalled() }) it('returns a 403 when the organization is not on an enterprise plan', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(true) - mockIsOrganizationGovernanceActive.mockResolvedValue(false) + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(false) const response = await authorizeOrgAccessControl('user-1', 'org-1') @@ -54,7 +56,7 @@ describe('authorizeOrgAccessControl', () => { it('returns null when the user is an admin and the org is entitled', async () => { mockIsOrganizationAdminOrOwner.mockResolvedValue(true) - mockIsOrganizationGovernanceActive.mockResolvedValue(true) + mockIsOrganizationPermissionRegimeActive.mockResolvedValue(true) const response = await authorizeOrgAccessControl('user-1', 'org-1') diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts index d05d1163aa3..026963a3a5a 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts @@ -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 { isOrganizationGovernanceActive } 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). */ @@ -32,10 +32,11 @@ export async function authorizeOrgAccessControl( } /** - * Governance, not the plan gate: an organization whose restrictions still apply has to be able - * to see and loosen them, so this matches what the Access Control page now allows. + * 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 governed = await isOrganizationGovernanceActive(organizationId) + const governed = await isOrganizationPermissionRegimeActive(organizationId) if (!governed) { return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 }) } diff --git a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx index ab5d7c8de34..6c03c4c8e5a 100644 --- a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx @@ -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 { @@ -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) diff --git a/apps/sim/lib/organizations/surface.test.ts b/apps/sim/lib/organizations/surface.test.ts index 8b6658401d4..1a74e24ae06 100644 --- a/apps/sim/lib/organizations/surface.test.ts +++ b/apps/sim/lib/organizations/surface.test.ts @@ -16,11 +16,11 @@ 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, - /** The nav lists Access Control on governance; these tests drive both from one knob. */ - isOrganizationGovernanceActive: mockEnterprisePlan, })) vi.mock('@/lib/knowledge/access/availability', () => ({ resolveKnowledgeAccessAvailability: mockSearchAccess, diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts index 66dddd0121c..399a97520b0 100644 --- a/apps/sim/lib/organizations/surface.ts +++ b/apps/sim/lib/organizations/surface.ts @@ -8,10 +8,7 @@ import { } from '@/components/settings/navigation' import type { OrganizationRole } from '@/lib/api/contracts/primitives' import type { DeploymentShape } from '@/lib/api/contracts/workspaces' -import { - isOrganizationGovernanceActive, - isOrganizationOnEnterprisePlan, -} from '@/lib/billing/core/subscription' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { isInvitationsDisabled } from '@/lib/core/config/env-flags' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' @@ -21,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 @@ -98,9 +98,16 @@ async function resolveOrganizationSurfaceContext( deployment.hosted && access.isAdmin ? isOrganizationOnEnterprisePlan(organizationId) : Promise.resolve(false), - /** Access Control stays listed while a payment is failing, because its rules still apply. */ + /** + * 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 - ? isOrganizationGovernanceActive(organizationId) + ? isOrganizationPermissionRegimeActive(organizationId).catch(() => false) : Promise.resolve(false), ]) return { diff --git a/apps/sim/lib/settings/application/organization-section-access.ts b/apps/sim/lib/settings/application/organization-section-access.ts index 5ae9e16843e..89aa4602dbe 100644 --- a/apps/sim/lib/settings/application/organization-section-access.ts +++ b/apps/sim/lib/settings/application/organization-section-access.ts @@ -3,14 +3,12 @@ import { isOrganizationSettingsSectionAvailable, type OrganizationSettingsSection, } from '@/components/settings/navigation' -import { - isOrganizationGovernanceActive, - isOrganizationOnEnterprisePlan, -} from '@/lib/billing/core/subscription' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' +import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' interface AuthorizeOrganizationSettingsSectionInput { organizationId: string @@ -37,18 +35,16 @@ export async function authorizeOrganizationSettingsSection({ const deployment = getDeploymentShape() const needsEnterprisePlan = deployment.hosted && section !== 'members' && section !== 'billing' /** - * Access Control is the one section whose availability follows governance rather than the plan - * gate, and it is the only one that reads it — so the extra lookup is scoped to that section - * instead of being paid on every settings page. + * Access Control's availability follows the permission regime rather than the plan gate, and no + * other section reads it — so each section pays for exactly one of the two lookups. */ - const [hasEnterprisePlan, governanceActive] = needsEnterprisePlan - ? await Promise.all([ - isOrganizationOnEnterprisePlan(organizationId), - section === 'access-control' - ? isOrganizationGovernanceActive(organizationId) - : Promise.resolve(false), - ]) - : [false, false] + const readsRegime = needsEnterprisePlan && section === 'access-control' + const [hasEnterprisePlan, governanceActive] = await Promise.all([ + needsEnterprisePlan && !readsRegime + ? isOrganizationOnEnterprisePlan(organizationId) + : Promise.resolve(false), + readsRegime ? isOrganizationPermissionRegimeActive(organizationId) : Promise.resolve(false), + ]) return isOrganizationSettingsSectionAvailable( section, diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index cb9724ac59e..6841e05fc1a 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -74,6 +74,10 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({ vi.mock('@/lib/knowledge/access/availability', () => ({ isKnowledgeMemberAccessAvailable: mocks.isKnowledgeMemberAccessAvailable, })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + /** Access Control follows the regime; these tests drive it from the same plan knob. */ + isOrganizationPermissionRegimeActive: mocks.isOrganizationOnEnterprisePlan, +})) vi.mock('@/lib/organizations/settings-access', () => ({ canOpenOrganizationSettingsSection: mocks.canOpenOrganizationSettingsSection, })) @@ -277,7 +281,27 @@ describe('authorizeWorkspaceSettingsSection', () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) await expect(authorize('access-control')).resolves.toEqual({ allowed: true }) - expect(mocks.getOrganizationSettingsFeatures).toHaveBeenCalledWith(true, mocks.deploymentShape) + /** + * Access Control is gated on the permission regime rather than the plan, so the plan lookup is + * skipped for it and the regime is what reaches the navigation gate. + */ + expect(mocks.getOrganizationSettingsFeatures).toHaveBeenCalledWith( + false, + mocks.deploymentShape, + true + ) + }) + + /** + * The workspace-scoped page reads the same regime as the organization one: an organization whose + * restrictions still apply during a failing payment must not have this page taken away. + */ + it('keeps the workspace Access Control page open while the organization is governed', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(false) + + await expect(authorize('access-control')).resolves.toEqual({ allowed: true }) + expect(mocks.isOrganizationOnEnterprisePlan).toHaveBeenCalledTimes(1) }) it('resolves the exact entitlement source only for gated workspace sections', async () => { diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 2f441121f74..01cca335dfe 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -14,6 +14,7 @@ import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' import { isAccessRequestEnabled } from '@/lib/permission-access-requests/settings' import type { BooleanPermissionGroupConfigKey } from '@/lib/permission-groups/features' +import { isOrganizationPermissionRegimeActive } from '@/lib/permission-groups/resolve.server' import { isPlatformAdmin } from '@/lib/permissions/super-user' import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access' import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations' @@ -114,17 +115,26 @@ async function canOpenOrganizationSection( } const needsEnterprisePlan = organizationSection !== 'members' && organizationSection !== 'billing' - const [canOpenSection, isEnterpriseOrganization] = await Promise.all([ + /** Same split as the organization surface: Access Control follows the regime, everything else the plan. */ + const readsRegime = needsEnterprisePlan && organizationSection === 'access-control' + const [canOpenSection, isEnterpriseOrganization, governanceActive] = await Promise.all([ canOpenOrganizationSettingsSection(workspace.organizationId, input.userId, organizationSection), - needsEnterprisePlan + needsEnterprisePlan && !readsRegime ? isOrganizationOnEnterprisePlan(workspace.organizationId) : Promise.resolve(false), + readsRegime + ? isOrganizationPermissionRegimeActive(workspace.organizationId) + : Promise.resolve(false), ]) return ( canOpenSection && isOrganizationSettingsSectionAvailable( organizationSection, - getOrganizationSettingsFeatures(needsEnterprisePlan && isEnterpriseOrganization, deployment) + getOrganizationSettingsFeatures( + needsEnterprisePlan && isEnterpriseOrganization, + deployment, + governanceActive + ) ) ) }