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..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, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({ - mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), - mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), -})) +const { mockIsOrganizationAdminOrOwner, mockIsOrganizationPermissionRegimeActive } = vi.hoisted( + () => ({ + mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), + mockIsOrganizationPermissionRegimeActive: vi.fn<() => Promise>(), + }) +) -vi.mock('@/lib/billing', () => ({ - isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +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) - mockIsOrganizationOnEnterprisePlan.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(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') @@ -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') 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..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 { 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). */ @@ -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 }) } 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/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/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..1a74e24ae06 100644 --- a/apps/sim/lib/organizations/surface.test.ts +++ b/apps/sim/lib/organizations/surface.test.ts @@ -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, diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts index ee2c3283c5a..399a97520b0 100644 --- a/apps/sim/lib/organizations/surface.ts +++ b/apps/sim/lib/organizations/surface.ts @@ -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 @@ -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), + ]) return { organization: { id: row.id, @@ -112,7 +132,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..89aa4602dbe 100644 --- a/apps/sim/lib/settings/application/organization-section-access.ts +++ b/apps/sim/lib/settings/application/organization-section-access.ts @@ -8,6 +8,7 @@ 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 @@ -33,12 +34,20 @@ export async function authorizeOrganizationSettingsSection({ const deployment = getDeploymentShape() const needsEnterprisePlan = deployment.hosted && section !== 'members' && section !== 'billing' - const hasEnterprisePlan = needsEnterprisePlan - ? await isOrganizationOnEnterprisePlan(organizationId) - : false + /** + * 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 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, - getOrganizationSettingsFeatures(hasEnterprisePlan, deployment) + getOrganizationSettingsFeatures(hasEnterprisePlan, deployment, governanceActive) ) } 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 + ) ) ) }