From e8b286dc8c4fb1578aebcc1294fea0abff302118 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 16:18:35 -0700 Subject: [PATCH 1/9] improvement(self-host): enterprise features enabling --- .../docs/en/platform/enterprise/index.mdx | 30 +- .../docs/en/platform/enterprise/meta.json | 1 + .../en/platform/enterprise/self-hosted.mdx | 211 +++++++ .../self-hosting/environment-variables.mdx | 13 + .../docs/en/platform/self-hosting/index.mdx | 13 + apps/sim/.env.example | 28 + .../app/api/auth/sso/register/route.test.ts | 8 + apps/sim/app/api/auth/sso/register/route.ts | 4 +- .../[id]/data-retention/route.ts | 58 +- .../[id]/session-policy/route.test.ts | 6 + .../[id]/session-policy/route.ts | 27 +- .../organizations/[id]/whitelabel/route.ts | 13 +- .../[id]/data-retention/route.ts | 135 +++++ .../v1/admin/organizations/[id]/route.test.ts | 122 ++++ .../api/v1/admin/organizations/[id]/route.ts | 118 ++++ .../[id]/session-policy/route.ts | 119 ++++ .../organizations/[id]/whitelabel/route.ts | 102 ++++ apps/sim/app/api/v1/admin/responses.ts | 5 + apps/sim/app/api/v1/audit-logs/auth.test.ts | 99 ++- apps/sim/app/api/v1/audit-logs/auth.ts | 54 +- apps/sim/components/settings/navigation.ts | 39 +- .../api/contracts/v1/admin/organizations.ts | 87 +++ apps/sim/lib/auth/auth.ts | 20 +- apps/sim/lib/auth/session-policy.ts | 6 +- .../lib/billing/cleanup-dispatcher.test.ts | 68 ++- apps/sim/lib/billing/cleanup-dispatcher.ts | 31 +- apps/sim/lib/billing/core/subscription.ts | 25 + .../organizations/create-organization.ts | 85 +-- apps/sim/lib/billing/retention.test.ts | 111 ++++ apps/sim/lib/billing/retention.ts | 74 +++ .../config/enterprise-entitlements.test.ts | 115 ++++ .../core/config/enterprise-entitlements.ts | 96 +++ apps/sim/lib/core/config/env-flags.ts | 161 +++-- apps/sim/lib/core/config/env.ts | 13 +- .../lib/organizations/instance-org.test.ts | 223 +++++++ apps/sim/lib/organizations/instance-org.ts | 295 +++++++++ apps/sim/lib/workspaces/policy.test.ts | 27 +- apps/sim/lib/workspaces/policy.ts | 26 +- apps/sim/package.json | 3 +- .../consolidate-users-into-organization.ts | 569 ++++++++++++++++++ helm/sim/values.yaml | 27 +- packages/audit/src/types.ts | 1 + packages/testing/src/mocks/audit.mock.ts | 1 + packages/testing/src/mocks/env-flags.mock.ts | 11 +- scripts/check-api-validation-contracts.ts | 4 +- scripts/setup/steps.ts | 7 + scripts/setup/twins.ts | 10 +- 47 files changed, 3059 insertions(+), 242 deletions(-) create mode 100644 apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx create mode 100644 apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts create mode 100644 apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts create mode 100644 apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts create mode 100644 apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts create mode 100644 apps/sim/lib/core/config/enterprise-entitlements.test.ts create mode 100644 apps/sim/lib/core/config/enterprise-entitlements.ts create mode 100644 apps/sim/lib/organizations/instance-org.test.ts create mode 100644 apps/sim/lib/organizations/instance-org.ts create mode 100644 apps/sim/scripts/consolidate-users-into-organization.ts diff --git a/apps/docs/content/docs/en/platform/enterprise/index.mdx b/apps/docs/content/docs/en/platform/enterprise/index.mdx index 6aee826a8af..f6ab6ad7b11 100644 --- a/apps/docs/content/docs/en/platform/enterprise/index.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/index.mdx @@ -81,19 +81,17 @@ Clone a workspace into a linked child, then push or pull **deployed** workflow c ## Self-hosted setup -Self-hosted deployments enable enterprise features via environment variables instead of billing. - -| Variable | Description | -|----------|-------------| -| `ORGANIZATIONS_ENABLED`, `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | Team and organization management | -| `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups | -| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | SAML and OIDC sign-in | -| `WHITELABELING_ENABLED`, `NEXT_PUBLIC_WHITELABELING_ENABLED` | Custom branding | -| `AUDIT_LOGS_ENABLED`, `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | Audit logging | -| `NEXT_PUBLIC_DATA_RETENTION_ENABLED` | Data retention configuration | -| `DATA_DRAINS_ENABLED`, `NEXT_PUBLIC_DATA_DRAINS_ENABLED` | Data drains | -| `FORKING_ENABLED`, `NEXT_PUBLIC_FORKING_ENABLED` | Workspace forking | -| `INBOX_ENABLED`, `NEXT_PUBLIC_INBOX_ENABLED` | Sim Mailer inbox | -| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Disable invitations; manage membership via Admin API | - -Once enabled, each feature is configured through the same Settings UI as Sim Cloud. When invitations are disabled, use the Admin API (`x-admin-key` header) to manage organization membership and workspace access. Internal members join the organization; external workspace members only receive access to a specific workspace. +Self-hosted deployments unlock enterprise features through environment configuration instead of billing. One switch turns on the whole set: + +```bash +ENTERPRISE_ENABLED=true +NEXT_PUBLIC_ENTERPRISE_ENABLED=true +``` + +Each feature also keeps its own flag, so you can enable them one at a time or switch a single feature back off. + +Most of these features read their settings from the organization that owns a workspace, so a deployment also needs an organization model — either one instance-wide organization that every user joins automatically, or organizations you provision yourself through the Admin API. + +See the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for the full variable list, both organization patterns, the Admin API reference, and troubleshooting. + +Once enabled, each feature is configured through the same Settings UI as Sim Cloud. When invitations are disabled (`DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS`), use the Admin API (`x-admin-key` header) to manage organization membership and workspace access. Internal members join the organization; external workspace members only receive access to a specific workspace. diff --git a/apps/docs/content/docs/en/platform/enterprise/meta.json b/apps/docs/content/docs/en/platform/enterprise/meta.json index 0b5066c495d..cdf420f1ff3 100644 --- a/apps/docs/content/docs/en/platform/enterprise/meta.json +++ b/apps/docs/content/docs/en/platform/enterprise/meta.json @@ -2,6 +2,7 @@ "title": "Enterprise", "pages": [ "index", + "self-hosted", "sso", "verified-domains", "session-policies", diff --git a/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx new file mode 100644 index 00000000000..db0889249d0 --- /dev/null +++ b/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx @@ -0,0 +1,211 @@ +--- +title: Self-hosted Enterprise +description: Run the full enterprise feature set on a self-hosted deployment without billing +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +On Sim Cloud, enterprise features are unlocked by an Enterprise subscription. Self-hosted deployments have no subscription, so they are unlocked by environment configuration instead. + +There are two parts to getting this right, and skipping the second is the most common reason features appear to do nothing: + +1. **Enable the features** with `ENTERPRISE_ENABLED`. +2. **Give them an organization to apply to.** Whitelabeling, PII redaction, permission groups, data drains, and audit scoping all read their settings from the organization that owns a workspace. A deployment where everyone works in personal workspaces has no organization for those settings to come from. + +## Enable the feature set + +Set the master switch and its client twin. Both are required — the server value decides access, and the `NEXT_PUBLIC_` value decides what the settings UI shows. + +```bash +ENTERPRISE_ENABLED=true +NEXT_PUBLIC_ENTERPRISE_ENABLED=true +``` + +That turns on organizations, permission groups, SSO, whitelabeling, audit logs, session policies, data retention, data drains, workspace forks, and the inbox. + +### Turning one feature off + +Every feature keeps its own flag, and an explicitly set flag always wins over the master switch. To run the suite without data drains: + +```bash +ENTERPRISE_ENABLED=true +NEXT_PUBLIC_ENTERPRISE_ENABLED=true +DATA_DRAINS_ENABLED=false +NEXT_PUBLIC_DATA_DRAINS_ENABLED=false +``` + +The individual flags also work on their own if you would rather opt in one at a time and leave the master switch unset. + +| Feature | Server variable | Client variable | +|---------|-----------------|-----------------| +| Everything below | `ENTERPRISE_ENABLED` | `NEXT_PUBLIC_ENTERPRISE_ENABLED` | +| Organizations | `ORGANIZATIONS_ENABLED` | `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | +| Permission groups | `ACCESS_CONTROL_ENABLED` | `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | +| SAML and OIDC sign-in | `SSO_ENABLED` | `NEXT_PUBLIC_SSO_ENABLED` | +| Custom branding | `WHITELABELING_ENABLED` | `NEXT_PUBLIC_WHITELABELING_ENABLED` | +| Audit logs | `AUDIT_LOGS_ENABLED` | `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | +| Session policies | `SESSION_POLICIES_ENABLED` | `NEXT_PUBLIC_SESSION_POLICIES_ENABLED` | +| Data retention deletion | `DATA_RETENTION_ENABLED` | `NEXT_PUBLIC_DATA_RETENTION_ENABLED` | +| Data drains | `DATA_DRAINS_ENABLED` | `NEXT_PUBLIC_DATA_DRAINS_ENABLED` | +| Workspace forks | `FORKING_ENABLED` | — | +| Sim Mailer inbox | `INBOX_ENABLED` | `NEXT_PUBLIC_INBOX_ENABLED` | + + + Data retention is the one feature that deletes data. Its flag controls the cleanup pass, not the settings screen — retention windows are always configurable. Nothing is ever deleted until you enable it, and even then only against windows you configured explicitly. Sim never applies the hosted plan defaults to a self-hosted deployment. + + +## Choose an organization model + +### Pattern 1: one organization for the whole instance + +Best when everyone on the deployment belongs to the same company. Set a name and every user joins that organization automatically at signup, with their workspaces created org-owned. + +```bash +INSTANCE_ORG_NAME="Acme Inc" +``` + +Optionally pin the slug and the owner: + +```bash +INSTANCE_ORG_SLUG=acme-inc +INSTANCE_ORG_OWNER_EMAIL=admin@acme.com +``` + +The organization is created the first time a user signs up. If `INSTANCE_ORG_OWNER_EMAIL` is not set, or names a user who does not exist yet, the first user to sign up becomes the owner; move ownership later with the Admin API. Provisioning is idempotent and safe across multiple replicas. + + + Instance-organization mode only applies when billing is disabled. With billing enabled, organizations are created through the normal subscription flow and these variables are ignored. + + +#### Existing deployments + +Users and workspaces created before you set `INSTANCE_ORG_NAME` stay where they are. Move them across once with the backfill script, which adds every user to the organization and attaches their workspaces: + +```bash +# Preview +DATABASE_URL=... INSTANCE_ORG_NAME="Acme Inc" \ + bun run apps/sim/scripts/consolidate-users-into-organization.ts + +# Apply +DATABASE_URL=... INSTANCE_ORG_NAME="Acme Inc" \ + bun run apps/sim/scripts/consolidate-users-into-organization.ts --apply +``` + +It is a dry run unless you pass `--apply`, and it is safe to re-run. Users who already belong to a different organization are reported and skipped, since a user can only belong to one. + +### Pattern 2: many organizations you manage yourself + +Best when one deployment serves several teams that should not see each other's data. Leave `INSTANCE_ORG_NAME` unset and provision organizations through the Admin API. + +Set an admin key first: + +```bash +ADMIN_API_KEY=$(openssl rand -hex 32) +``` + + + +### Create an organization + +The owner must not already belong to another organization. + +```bash +curl -X POST https://sim.example.com/api/v1/admin/organizations \ + -H "x-admin-key: $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name": "Acme Inc", "ownerId": "user_123", "slug": "acme-inc"}' +``` + + + +### Add members + +```bash +curl -X POST https://sim.example.com/api/v1/admin/organizations/$ORG_ID/members \ + -H "x-admin-key: $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"userId": "user_456", "role": "member"}' +``` + + + +### Move a workspace into the organization + +Organization-scoped features only apply to workspaces the organization owns. + +```bash +curl -X POST https://sim.example.com/api/v1/admin/dashboard/workspaces/$WORKSPACE_ID/move \ + -H "x-admin-key: $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"destinationOrganizationId\": \"$ORG_ID\"}" +``` + + + +### Configure organization settings + +Branding, retention, and session policies can be set from the API instead of the UI. + + + +```bash +curl -X PATCH https://sim.example.com/api/v1/admin/organizations/$ORG_ID/whitelabel \ + -H "x-admin-key: $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"brandName": "Acme AI", "hidePoweredBySim": true}' +``` + + +```bash +curl -X PATCH https://sim.example.com/api/v1/admin/organizations/$ORG_ID/data-retention \ + -H "x-admin-key: $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"logRetentionHours": 2160}' +``` + + +```bash +curl -X PATCH https://sim.example.com/api/v1/admin/organizations/$ORG_ID/session-policy \ + -H "x-admin-key: $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"maxSessionHours": 168, "idleTimeoutHours": 48}' +``` + + + + + +Deleting an organization requires echoing its slug, because the delete cascades to members, invitations, and permission groups, and detaches its workspaces: + +```bash +curl -X DELETE "https://sim.example.com/api/v1/admin/organizations/$ORG_ID?confirmSlug=acme-inc" \ + -H "x-admin-key: $ADMIN_API_KEY" +``` + +## Verifying it worked + +If a feature is enabled but nothing appears, check these in order. + +**The settings section is missing.** The `NEXT_PUBLIC_` twin is not set, or the app was not restarted after adding it. Client variables are read at build and boot. + +**The section appears but the API returns 403.** The server-side variable is missing while its client twin is set. Set both. + +**The feature is on but has no effect inside a workspace.** The workspace is not owned by an organization. Check `workspace_mode` and `organization_id`: + +```sql +SELECT id, name, workspace_mode, organization_id FROM workspace; +``` + +A workspace showing `personal` or a null `organization_id` will not pick up branding, PII redaction, permission groups, or drains. Use the backfill script or the workspace move endpoint. + +**Retention is configured but nothing is deleted.** `DATA_RETENTION_ENABLED` is unset. Configuring windows and running the cleanup pass are separate switches by design. + +## Related + +- [Environment variables](/platform/self-hosting/environment-variables) +- [Single Sign-On](/platform/enterprise/sso) +- [Access control](/platform/enterprise/access-control) +- [Roles and permissions](/platform/permissions) +- [Workspaces](/platform/workspaces) diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 0e084dfc858..7e8f86e958c 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -70,6 +70,19 @@ import { Callout } from 'fumadocs-ui/components/callout' | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | | `DISABLE_REGISTRATION` | Set to `true` to disable new user signups | +## Enterprise Features + +Enterprise features are unlocked by configuration rather than billing on self-hosted deployments. One switch turns on the full set; per-feature flags below it override the switch either way. + +| Variable | Description | +|----------|-------------| +| `ENTERPRISE_ENABLED`, `NEXT_PUBLIC_ENTERPRISE_ENABLED` | Enable the whole enterprise feature set | +| `INSTANCE_ORG_NAME` | Name of the organization every user joins automatically at signup | +| `INSTANCE_ORG_SLUG` | Slug for that organization (derived from the name when omitted) | +| `INSTANCE_ORG_OWNER_EMAIL` | Owner of that organization (defaults to the first user to sign up) | + +Most enterprise features read their settings from the organization that owns a workspace, so enabling the flags alone is not enough — the deployment also needs an organization model. See the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for the per-feature flags, both organization patterns, and the Admin API. + ## File Storage By default Sim writes uploads to local disk. For production, point it at AWS S3, Azure Blob, or Google Cloud Storage. See [Object Storage](/platform/self-hosting/object-storage) for the full setup, bucket layout, and IAM policy. diff --git a/apps/docs/content/docs/en/platform/self-hosting/index.mdx b/apps/docs/content/docs/en/platform/self-hosting/index.mdx index c31c30423b7..1cecb325ac0 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/index.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/index.mdx @@ -55,6 +55,19 @@ Open [http://localhost:3000](http://localhost:3000) +## Enterprise Features + +Organizations, SSO, permission groups, audit logs, whitelabeling, session policies, data retention, and data drains all run on a self-hosted deployment — no billing or subscription required. One switch turns on the set: + +```bash +ENTERPRISE_ENABLED=true +NEXT_PUBLIC_ENTERPRISE_ENABLED=true +``` + +Most of these features read their settings from the organization that owns a workspace, so enabling the flags is only half of it — your deployment also needs an organization model. Set `INSTANCE_ORG_NAME` to put every user in one shared organization automatically, or provision organizations yourself through the Admin API. + +See the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for both patterns, the per-feature flags, and troubleshooting. + ## Architecture | Component | Port | Description | diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 795a002a6ad..ef123188499 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -141,6 +141,34 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import. # Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces +# Enterprise Features (Optional - self-hosted). One switch enables organizations, SSO, +# permission groups, audit logs, whitelabeling, session policies, data retention, data +# drains, forks, and the inbox. Set both — the server value grants access, the +# NEXT_PUBLIC_ value decides what the settings UI shows. +# Docs: https://docs.sim.ai/platform/enterprise/self-hosted +# ENTERPRISE_ENABLED=true +# NEXT_PUBLIC_ENTERPRISE_ENABLED=true + +# Per-feature overrides. An explicitly set flag wins over ENTERPRISE_ENABLED, so use these +# to enable one feature on its own or to switch a single feature back off. +# ACCESS_CONTROL_ENABLED= / NEXT_PUBLIC_ACCESS_CONTROL_ENABLED= # Permission groups +# SSO_ENABLED= / NEXT_PUBLIC_SSO_ENABLED= # SAML and OIDC sign-in +# WHITELABELING_ENABLED= / NEXT_PUBLIC_WHITELABELING_ENABLED= # Custom branding +# AUDIT_LOGS_ENABLED= / NEXT_PUBLIC_AUDIT_LOGS_ENABLED= # Audit logging +# SESSION_POLICIES_ENABLED= / NEXT_PUBLIC_SESSION_POLICIES_ENABLED= +# DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default +# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams +# FORKING_ENABLED= # Workspace forks +# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only + +# Instance organization (Optional). Most enterprise features read their settings from the +# organization that owns a workspace, so a deployment needs an organization for them to +# apply. Setting a name puts every user in one shared org at signup and makes their +# workspaces org-owned. Leave unset to manage organizations yourself via the Admin API. +# INSTANCE_ORG_NAME=Acme Inc +# INSTANCE_ORG_SLUG=acme-inc # Optional — derived from the name when unset +# INSTANCE_ORG_OWNER_EMAIL=admin@acme.com # Optional — defaults to the first user to sign up + # Limits (Optional - self-hosted). With billing disabled (BILLING_ENABLED unset), no plan # limits are enforced. Explicitly setting a free-tier variable below opts that specific # limit back in at the configured value. diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts index 02d10f217d1..3a4e5c752b2 100644 --- a/apps/sim/app/api/auth/sso/register/route.test.ts +++ b/apps/sim/app/api/auth/sso/register/route.test.ts @@ -7,9 +7,11 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, + resetEnvFlagsMock, resetEnvMock, schemaMock, setEnv, + setEnvFlags, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -96,6 +98,11 @@ describe('POST /api/auth/sso/register', () => { vi.clearAllMocks() resetDbChainMock() setEnv({ SSO_ENABLED: 'true' }) + /** + * The route gates on the resolved `isSsoEnabled` rather than the raw env + * var, so the suite switch (`ENTERPRISE_ENABLED`) can register SSO too. + */ + setEnvFlags({ isSsoEnabled: true }) mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) mockHasSSOAccess.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) @@ -116,6 +123,7 @@ describe('POST /api/auth/sso/register', () => { afterAll(() => { resetDbChainMock() resetEnvMock() + resetEnvFlagsMock() }) it('rejects callers without an Enterprise plan', async () => { diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index 0c78d76f216..8defefba9b8 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -8,7 +8,7 @@ import { ssoRegistrationContract } from '@/lib/api/contracts/auth' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth' import { hasSSOAccess } from '@/lib/billing' -import { env } from '@/lib/core/config/env' +import { isSsoEnabled } from '@/lib/core/config/env-flags' import { secureFetchWithPinnedIP, validateUrlWithDNS, @@ -68,7 +68,7 @@ async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise { try { - if (!env.SSO_ENABLED) { + if (!isSsoEnabled) { return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) } diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index d8e71ebf435..1811b6a355e 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -13,6 +13,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { getPiiRedactionDenialReason } from '@/lib/billing/retention' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -65,27 +66,6 @@ function normalizeConfigured( } } -/** - * Which granular stages (`input`/`blockOutputs`) are already enabled per rule - * target (`workspaceId ?? ''` = the org default). Used to gate the - * `pii-granular-redaction` flag on *new* enablement only: when the flag is off, - * an org that already configured granular stages must still be able to re-save - * unrelated settings (the UI re-sends the full PII snapshot every save), so we - * reject only a stage transitioning off→on, never a preserved one. - */ -function granularStageEnablement( - settings: OrganizationRetentionValues['piiRedaction'] -): Map { - const map = new Map() - for (const rule of settings?.rules ?? []) { - map.set(rule.workspaceId ?? '', { - input: rule.stages?.input?.enabled === true, - blockOutputs: rule.stages?.blockOutputs?.enabled === true, - }) - } - return map -} - /** * GET /api/organizations/[id]/data-retention * Returns the organization's data retention settings. @@ -225,34 +205,14 @@ export const PUT = withRouteHandler( merged.taskCleanupHours = body.taskCleanupHours } if (body.piiRedaction !== undefined) { - if (!piiRedactionEnabled) { - return NextResponse.json( - { error: 'PII redaction is not enabled for this organization' }, - { status: 403 } - ) - } - if (!piiGranularRedactionEnabled) { - // Reject only a granular stage transitioning off→on; a body that merely - // preserves already-enabled granular stages must still save (the UI - // re-sends the full snapshot on every save), so existing orgs aren't - // locked out of unrelated retention changes when the flag is off. - const currentGranular = granularStageEnablement(current.piiRedaction) - const newlyEnablesGranular = (body.piiRedaction?.rules ?? []).some((rule) => { - const cur = currentGranular.get(rule.workspaceId ?? '') - return ( - (rule.stages?.input?.enabled === true && !cur?.input) || - (rule.stages?.blockOutputs?.enabled === true && !cur?.blockOutputs) - ) - }) - if (newlyEnablesGranular) { - return NextResponse.json( - { - error: - 'Granular PII redaction (workflow input and block outputs) is not enabled for this organization', - }, - { status: 403 } - ) - } + const denialReason = getPiiRedactionDenialReason({ + current: current.piiRedaction, + incoming: body.piiRedaction, + piiRedactionEnabled, + piiGranularRedactionEnabled, + }) + if (denialReason) { + return NextResponse.json({ error: denialReason }, { status: 403 }) } merged.piiRedaction = body.piiRedaction } diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts index c8722a51154..65183a0e83d 100644 --- a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts @@ -28,8 +28,14 @@ vi.mock('@/lib/auth/security-policy', () => ({ invalidateSecurityPolicyVersionCache: vi.fn(), })) +/** + * These tests run with billing enabled, where `isOrganizationFeatureEntitled` + * delegates straight to the plan check — so both names resolve to the same + * mock and `mockIsEnterprise` keeps steering the gate. + */ vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockIsEnterprise, + isOrganizationFeatureEntitled: mockIsEnterprise, })) vi.mock('@sim/audit', () => ({ diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.ts index d3ce9d53bc3..fca76741506 100644 --- a/apps/sim/app/api/organizations/[id]/session-policy/route.ts +++ b/apps/sim/app/api/organizations/[id]/session-policy/route.ts @@ -11,8 +11,8 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy' import { eagerClampOrgSessions, invalidateSessionPolicyCache } from '@/lib/auth/session-policy' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { isBillingEnabled, isSessionPoliciesEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SessionPolicyAPI') @@ -60,7 +60,10 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) } - const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + const isEnterprise = await isOrganizationFeatureEntitled( + organizationId, + isSessionPoliciesEnabled + ) return NextResponse.json({ success: true, @@ -113,14 +116,16 @@ export const PUT = withRouteHandler( ) } - if (isBillingEnabled) { - const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId) - if (!hasEnterprise) { - return NextResponse.json( - { error: 'Session policies are available on Enterprise plans only' }, - { status: 403 } - ) - } + const entitled = await isOrganizationFeatureEntitled(organizationId, isSessionPoliciesEnabled) + if (!entitled) { + return NextResponse.json( + { + error: isBillingEnabled + ? 'Session policies are available on Enterprise plans only' + : 'Session policies are disabled. Set ENTERPRISE_ENABLED or SESSION_POLICIES_ENABLED to enable them.', + }, + { status: 403 } + ) } const [currentOrg] = await db diff --git a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts index f74309815a5..d2812453bfc 100644 --- a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts +++ b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts @@ -7,8 +7,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { updateOrganizationWhitelabelContract } from '@/lib/api/contracts/organization' import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' import type { OrganizationWhitelabelSettings } from '@/lib/branding/types' +import { isBillingEnabled, isWhitelabelingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('WhitelabelAPI') @@ -107,11 +108,15 @@ export const PUT = withRouteHandler( ) } - const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId) + const entitled = await isOrganizationFeatureEntitled(organizationId, isWhitelabelingEnabled) - if (!hasEnterprisePlan) { + if (!entitled) { return NextResponse.json( - { error: 'Whitelabeling is available on Enterprise plans only' }, + { + error: isBillingEnabled + ? 'Whitelabeling is available on Enterprise plans only' + : 'Whitelabeling is disabled. Set ENTERPRISE_ENABLED or WHITELABELING_ENABLED to enable it.', + }, { status: 403 } ) } diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts new file mode 100644 index 00000000000..f7bee24d40f --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts @@ -0,0 +1,135 @@ +/** + * PATCH /api/v1/admin/organizations/[id]/data-retention + * + * Set an organization's data-retention settings without going through the + * settings UI, so a self-hosted deployment can provision retention windows and + * PII redaction rules from its own configuration management. + * + * Retention windows only take effect once the cleanup pass is enabled — set + * `DATA_RETENTION_ENABLED` (or `ENTERPRISE_ENABLED`) when billing is off. + * + * Body: any subset of `logRetentionHours`, `softDeleteRetentionHours`, + * `taskCleanupHours`, `piiRedaction`, `retentionOverrides`. Omitted keys keep + * their current value; `null` means "forever" for an hours field. + * + * Response: AdminSingleResponse<{ success, organizationId }> + */ + +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import type { DataRetentionSettings } from '@sim/db/schema' +import { organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { adminV1UpdateOrganizationDataRetentionContract } from '@/lib/api/contracts/v1/admin' +import { parseRequest } from '@/lib/api/server' +import { getPiiRedactionDenialReason } from '@/lib/billing/retention' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + adminInvalidJsonResponse, + adminValidationErrorResponse, + forbiddenResponse, + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' + +const logger = createLogger('AdminOrganizationDataRetentionAPI') + +interface RouteParams { + id: string +} + +export const PATCH = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest( + adminV1UpdateOrganizationDataRetentionContract, + request, + context, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + } + ) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const body = parsed.data.body + + try { + const [existing] = await db + .select({ + name: organization.name, + dataRetentionSettings: organization.dataRetentionSettings, + }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!existing) { + return notFoundResponse('Organization') + } + + const merged: DataRetentionSettings = { ...(existing.dataRetentionSettings ?? {}) } + if (body.logRetentionHours !== undefined) merged.logRetentionHours = body.logRetentionHours + if (body.softDeleteRetentionHours !== undefined) { + merged.softDeleteRetentionHours = body.softDeleteRetentionHours + } + if (body.taskCleanupHours !== undefined) merged.taskCleanupHours = body.taskCleanupHours + + if (body.piiRedaction !== undefined) { + /** + * The same gate the settings UI applies. An admin key authenticates an + * operator, not an entitlement — without this check it would be a way + * to switch on PII redaction that the product does not offer this + * organization. + */ + const [piiRedactionEnabled, piiGranularRedactionEnabled] = await Promise.all([ + isFeatureEnabled('pii-redaction'), + isFeatureEnabled('pii-granular-redaction'), + ]) + const denialReason = getPiiRedactionDenialReason({ + current: existing.dataRetentionSettings?.piiRedaction, + incoming: body.piiRedaction, + piiRedactionEnabled, + piiGranularRedactionEnabled, + }) + if (denialReason) return forbiddenResponse(denialReason) + + merged.piiRedaction = body.piiRedaction + } + + if (body.retentionOverrides !== undefined) { + merged.retentionOverrides = body.retentionOverrides + } + + await db + .update(organization) + .set({ dataRetentionSettings: merged, updatedAt: new Date() }) + .where(eq(organization.id, organizationId)) + + logger.info(`Admin API: Updated data retention for organization ${organizationId}`, { + fields: Object.keys(body), + }) + + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + resourceName: existing.name, + description: `Admin API updated data retention for "${existing.name}"`, + metadata: { fields: Object.keys(body) }, + request, + }) + + return singleResponse({ success: true as const, organizationId }) + } catch (error) { + logger.error('Admin API: Failed to update data retention', { error, organizationId }) + return internalErrorResponse('Failed to update data retention') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts new file mode 100644 index 00000000000..848b36193ae --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDetachOrganizationWorkspaces, mockDelete, mockAuthenticateAdminRequest } = vi.hoisted( + () => ({ + mockDetachOrganizationWorkspaces: vi.fn(), + mockDelete: vi.fn(), + mockAuthenticateAdminRequest: vi.fn(), + }) +) + +vi.mock('@/lib/workspaces/organization-workspaces', () => ({ + detachOrganizationWorkspaces: mockDetachOrganizationWorkspaces, +})) + +vi.mock('@/app/api/v1/admin/auth', () => ({ + authenticateAdminRequest: mockAuthenticateAdminRequest, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { + ORGANIZATION_UPDATED: 'organization.updated', + ORGANIZATION_DELETED: 'organization.deleted', + }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +import { DELETE } from '@/app/api/v1/admin/organizations/[id]/route' + +const ORG_ID = 'org-1' +const routeContext = { params: Promise.resolve({ id: ORG_ID }) } + +function deleteRequest(confirmSlug?: string) { + const query = confirmSlug === undefined ? '' : `?confirmSlug=${encodeURIComponent(confirmSlug)}` + return createMockRequest( + 'DELETE', + undefined, + {}, + `http://localhost:3000/api/v1/admin/organizations/${ORG_ID}${query}` + ) +} + +/** Queues the organization lookup the handler runs before any guard. */ +function queueOrganization(slug = 'acme-inc') { + queueTableRows(schemaMock.organization, [{ id: ORG_ID, name: 'Acme', slug }]) +} + +describe('admin organization DELETE', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockAuthenticateAdminRequest.mockReturnValue({ authenticated: true }) + mockDetachOrganizationWorkspaces.mockResolvedValue({ + detachedWorkspaceIds: ['ws-1', 'ws-2'], + billedAccountUserId: 'user-1', + }) + mockDelete.mockClear() + }) + + afterAll(resetDbChainMock) + + it('returns 404 when the organization does not exist', async () => { + queueTableRows(schemaMock.organization, []) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + expect(response.status).toBe(404) + expect(mockDetachOrganizationWorkspaces).not.toHaveBeenCalled() + }) + + it('refuses when confirmSlug does not match the organization slug', async () => { + queueOrganization('acme-inc') + + const response = await DELETE(deleteRequest('wrong-slug'), routeContext) + + expect(response.status).toBe(400) + expect(mockDetachOrganizationWorkspaces).not.toHaveBeenCalled() + }) + + it('refuses with 409 while a subscription still references the organization', async () => { + queueOrganization() + queueTableRows(schemaMock.subscription, [{ id: 'sub-1', plan: 'enterprise' }]) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + /** + * `subscription.reference_id` has no foreign key, so deleting here would + * strand the row and its Stripe billing against a dangling id. + */ + expect(response.status).toBe(409) + expect(mockDetachOrganizationWorkspaces).not.toHaveBeenCalled() + }) + + it('detaches workspaces before deleting the organization', async () => { + queueOrganization() + queueTableRows(schemaMock.subscription, []) + queueTableRows(schemaMock.member, [{ value: 3 }]) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + expect(response.status).toBe(200) + /** + * The `ON DELETE SET NULL` foreign key alone would leave workspaces in + * `organization` mode with a null org id and drop the organization's + * storage ledger without crediting the new payer. + */ + expect(mockDetachOrganizationWorkspaces).toHaveBeenCalledWith(ORG_ID) + + const body = await response.json() + expect(body.data).toMatchObject({ + success: true, + organizationId: ORG_ID, + slug: 'acme-inc', + membersRemoved: 3, + workspacesDetached: 2, + }) + }) +}) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index a1ef18a9cd0..b0ad98d7a5c 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -14,6 +14,20 @@ * - slug?: string - Organization slug * * Response: AdminSingleResponse + * + * DELETE /api/v1/admin/organizations/[id] + * + * Permanently delete an organization. Members, invitations, permission groups, + * and org-level settings go with it. Its workspaces are detached first — moved + * back to their own billing account with their storage ledger transferred — and + * survive the delete. + * + * Refuses with 409 when a subscription still references the organization. + * + * Query: + * - confirmSlug: string - Must equal the organization's slug + * + * Response: AdminSingleResponse<{ success, organizationId, slug, membersRemoved, workspacesDetached }> */ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' @@ -22,6 +36,7 @@ import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, inArray } from 'drizzle-orm' import { + adminV1DeleteOrganizationContract, adminV1GetOrganizationContract, adminV1UpdateOrganizationContract, } from '@/lib/api/contracts/v1/admin' @@ -34,11 +49,13 @@ import { } from '@/lib/billing/organizations/create-organization' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, + conflictResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -191,3 +208,104 @@ export const PATCH = withRouteHandler( } }) ) + +export const DELETE = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest(adminV1DeleteOrganizationContract, request, context, { + validationErrorResponse: adminValidationErrorResponse, + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const { confirmSlug } = parsed.data.query + + try { + const [existing] = await db + .select({ id: organization.id, name: organization.name, slug: organization.slug }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!existing) { + return notFoundResponse('Organization') + } + + if (confirmSlug !== existing.slug) { + return badRequestResponse( + `confirmSlug does not match this organization's slug. Pass confirmSlug=${existing.slug} to confirm deletion.` + ) + } + + /** + * `subscription.reference_id` is polymorphic — it holds either a user id + * or an organization id — so it carries no foreign key and nothing + * cascades. Deleting an organization out from under a live subscription + * would strand it, and its Stripe billing, against an id that no longer + * resolves. Refuse instead of guessing whether to cancel. + */ + const [existingSubscription] = await db + .select({ id: subscription.id, plan: subscription.plan }) + .from(subscription) + .where(eq(subscription.referenceId, organizationId)) + .limit(1) + + if (existingSubscription) { + return conflictResponse( + `Organization still has a "${existingSubscription.plan}" subscription. Cancel it before deleting the organization.` + ) + } + + const [memberCountRow] = await db + .select({ value: count() }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + /** + * Detach before deleting rather than leaning on the `ON DELETE SET NULL` + * foreign key. The key only clears `organization_id`; it leaves + * `workspace_mode` at `organization` — a state the cleanup dispatcher + * treats as malformed and skips — keeps `billed_account_user_id` pointed + * at the departing organization, and drops the organization's storage + * ledger without returning those bytes to the workspace's new payer. + * This helper does the payer transfer, resets the mode, and re-grants + * admin permissions. + */ + const { detachedWorkspaceIds } = await detachOrganizationWorkspaces(organizationId) + + await db.delete(organization).where(eq(organization.id, organizationId)) + + logger.info(`Admin API: Deleted organization ${organizationId}`, { + slug: existing.slug, + membersRemoved: memberCountRow?.value ?? 0, + workspacesDetached: detachedWorkspaceIds.length, + }) + + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORGANIZATION_DELETED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + resourceName: existing.name, + description: `Admin API deleted organization "${existing.name}"`, + metadata: { + slug: existing.slug, + membersRemoved: memberCountRow?.value ?? 0, + workspacesDetached: detachedWorkspaceIds.length, + }, + request, + }) + + return singleResponse({ + success: true as const, + organizationId, + slug: existing.slug, + membersRemoved: memberCountRow?.value ?? 0, + workspacesDetached: detachedWorkspaceIds.length, + }) + } catch (error) { + logger.error('Admin API: Failed to delete organization', { error, organizationId }) + return internalErrorResponse('Failed to delete organization') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts new file mode 100644 index 00000000000..c666f3aaded --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts @@ -0,0 +1,119 @@ +/** + * PATCH /api/v1/admin/organizations/[id]/session-policy + * + * Set an organization's session policy without going through the settings UI, + * so a self-hosted deployment can provision it from its own configuration + * management. + * + * Body: + * - maxSessionHours: number | null - Absolute session lifetime cap + * - idleTimeoutHours: number | null - Idle timeout + * + * Response: AdminSingleResponse<{ success, organizationId }> + */ + +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq, sql } from 'drizzle-orm' +import { adminV1UpdateOrganizationSessionPolicyContract } from '@/lib/api/contracts/v1/admin' +import { parseRequest } from '@/lib/api/server' +import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy' +import { eagerClampOrgSessions, invalidateSessionPolicyCache } from '@/lib/auth/session-policy' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + adminInvalidJsonResponse, + adminValidationErrorResponse, + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' + +const logger = createLogger('AdminOrganizationSessionPolicyAPI') + +interface RouteParams { + id: string +} + +export const PATCH = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest( + adminV1UpdateOrganizationSessionPolicyContract, + request, + context, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + } + ) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const body = parsed.data.body + + try { + const [existing] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!existing) { + return notFoundResponse('Organization') + } + + const merged = { + maxSessionHours: body.maxSessionHours, + idleTimeoutHours: body.idleTimeoutHours, + } + + /** + * Mirrors the settings-UI write: the policy, the security-policy version + * bump, and the clamp of already-issued sessions commit together, so a + * stored policy is never left unenforced by a partial failure. + */ + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(organization) + .set({ + sessionPolicySettings: merged, + securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1`, + updatedAt: new Date(), + }) + .where(eq(organization.id, organizationId)) + .returning({ id: organization.id }) + if (!row) return null + await eagerClampOrgSessions(organizationId, merged, tx) + return row + }) + + if (!updated) { + return notFoundResponse('Organization') + } + + invalidateSessionPolicyCache(organizationId) + invalidateSecurityPolicyVersionCache(organizationId) + + logger.info(`Admin API: Updated session policy for organization ${organizationId}`) + + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORGANIZATION_SESSION_POLICY_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + resourceName: existing.name, + description: `Admin API updated the session policy for "${existing.name}"`, + metadata: { changes: body }, + request, + }) + + return singleResponse({ success: true as const, organizationId }) + } catch (error) { + logger.error('Admin API: Failed to update session policy', { error, organizationId }) + return internalErrorResponse('Failed to update session policy') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts new file mode 100644 index 00000000000..f6ebbdce562 --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts @@ -0,0 +1,102 @@ +/** + * PATCH /api/v1/admin/organizations/[id]/whitelabel + * + * Set an organization's whitelabel settings without going through the settings + * UI, so a self-hosted deployment can provision branding from its own + * configuration management. + * + * Body: partial whitelabel settings; provided keys are merged over the current + * value and an explicit `null` clears a key. + * + * Response: AdminSingleResponse<{ success, organizationId }> + */ + +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { adminV1UpdateOrganizationWhitelabelContract } from '@/lib/api/contracts/v1/admin' +import { parseRequest } from '@/lib/api/server' +import type { OrganizationWhitelabelSettings } from '@/lib/branding/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + adminInvalidJsonResponse, + adminValidationErrorResponse, + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' + +const logger = createLogger('AdminOrganizationWhitelabelAPI') + +interface RouteParams { + id: string +} + +export const PATCH = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const parsed = await parseRequest( + adminV1UpdateOrganizationWhitelabelContract, + request, + context, + { + validationErrorResponse: adminValidationErrorResponse, + invalidJsonResponse: adminInvalidJsonResponse, + } + ) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const incoming = parsed.data.body + + try { + const [existing] = await db + .select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!existing) { + return notFoundResponse('Organization') + } + + const merged: OrganizationWhitelabelSettings = { ...(existing.whitelabelSettings ?? {}) } + for (const key of Object.keys(incoming) as Array) { + const value = incoming[key] + if (value === null || value === undefined) { + delete merged[key] + } else { + Object.assign(merged, { [key]: value }) + } + } + + await db + .update(organization) + .set({ whitelabelSettings: merged, updatedAt: new Date() }) + .where(eq(organization.id, organizationId)) + + logger.info(`Admin API: Updated whitelabel settings for organization ${organizationId}`, { + fields: Object.keys(incoming), + }) + + recordAudit({ + workspaceId: null, + actorId: 'admin-api', + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + resourceName: existing.name, + description: `Admin API updated whitelabel settings for "${existing.name}"`, + metadata: { fields: Object.keys(incoming) }, + request, + }) + + return singleResponse({ success: true as const, organizationId }) + } catch (error) { + logger.error('Admin API: Failed to update whitelabel settings', { error, organizationId }) + return internalErrorResponse('Failed to update whitelabel settings') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/responses.ts b/apps/sim/app/api/v1/admin/responses.ts index 592a5b0a0c0..9308df895dc 100644 --- a/apps/sim/app/api/v1/admin/responses.ts +++ b/apps/sim/app/api/v1/admin/responses.ts @@ -71,6 +71,11 @@ export function badRequestResponse(message: string, details?: unknown): NextResp return errorResponse('BAD_REQUEST', message, 400, details) } +/** The request is well-formed but conflicts with the resource's current state. */ +export function conflictResponse(message: string, details?: unknown): NextResponse { + return errorResponse('CONFLICT', message, 409, details) +} + export function adminValidationErrorResponse(error: z.ZodError): NextResponse { return badRequestResponse( getValidationErrorMessage(error, 'Invalid request body'), diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts index a16dee2b0c4..e8122de36dd 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.test.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockIsOrganizationBillingBlocked } = vi.hoisted(() => ({ @@ -19,30 +26,86 @@ describe('enterprise audit access', () => { vi.clearAllMocks() resetDbChainMock() mockIsOrganizationBillingBlocked.mockResolvedValue(false) - queueTableRows(schemaMock.member, [{ organizationId: 'organization-route', role: 'admin' }]) - queueTableRows(schemaMock.subscription, [{ id: 'subscription-1' }]) - queueTableRows(schemaMock.member, [{ userId: 'viewer' }, { userId: 'member-2' }]) }) afterAll(() => { resetDbChainMock() + resetEnvFlagsMock() }) - it('authorizes and bills against the organization named by the route', async () => { - await expect(validateEnterpriseAuditAccess('viewer', 'organization-route')).resolves.toEqual({ - success: true, - context: { - organizationId: 'organization-route', - orgMemberIds: ['viewer', 'member-2'], - }, + describe('with billing enabled', () => { + beforeEach(() => { + setEnvFlags({ isBillingEnabled: true }) + queueTableRows(schemaMock.member, [{ organizationId: 'organization-route', role: 'admin' }]) + queueTableRows(schemaMock.subscription, [{ id: 'subscription-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'viewer' }, { userId: 'member-2' }]) }) - expect(dbChainMockFns.where).toHaveBeenNthCalledWith(1, { - type: 'and', - conditions: [ - { type: 'eq', left: schemaMock.member.userId, right: 'viewer' }, - { type: 'eq', left: schemaMock.member.organizationId, right: 'organization-route' }, - ], + + it('authorizes and bills against the organization named by the route', async () => { + await expect(validateEnterpriseAuditAccess('viewer', 'organization-route')).resolves.toEqual({ + success: true, + context: { + organizationId: 'organization-route', + orgMemberIds: ['viewer', 'member-2'], + }, + }) + expect(dbChainMockFns.where).toHaveBeenNthCalledWith(1, { + type: 'and', + conditions: [ + { type: 'eq', left: schemaMock.member.userId, right: 'viewer' }, + { type: 'eq', left: schemaMock.member.organizationId, right: 'organization-route' }, + ], + }) + expect(mockIsOrganizationBillingBlocked).toHaveBeenCalledWith('organization-route') + }) + }) + + describe('with billing disabled', () => { + beforeEach(() => { + setEnvFlags({ isBillingEnabled: false }) + }) + + it('authorizes on the audit-logs entitlement without any subscription row', async () => { + setEnvFlags({ isAuditLogsEnabled: true }) + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'owner' }]) + queueTableRows(schemaMock.member, [{ userId: 'viewer' }, { userId: 'member-2' }]) + + await expect(validateEnterpriseAuditAccess('viewer')).resolves.toEqual({ + success: true, + context: { organizationId: 'org-1', orgMemberIds: ['viewer', 'member-2'] }, + }) + /** + * The subscription lookup is what made audit logs unreachable + * self-hosted; a billing-free deployment never has one to find. + */ + expect(mockIsOrganizationBillingBlocked).not.toHaveBeenCalled() + }) + + it('refuses when the audit-logs entitlement is off', async () => { + setEnvFlags({ isAuditLogsEnabled: false }) + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'owner' }]) + + const result = await validateEnterpriseAuditAccess('viewer') + + expect(result.success).toBe(false) + }) + + it('still requires an admin or owner role', async () => { + setEnvFlags({ isAuditLogsEnabled: true }) + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'member' }]) + + const result = await validateEnterpriseAuditAccess('viewer') + + expect(result.success).toBe(false) + }) + + it('still requires organization membership', async () => { + setEnvFlags({ isAuditLogsEnabled: true }) + queueTableRows(schemaMock.member, []) + + const result = await validateEnterpriseAuditAccess('viewer') + + expect(result.success).toBe(false) }) - expect(mockIsOrganizationBillingBlocked).toHaveBeenCalledWith('organization-route') }) }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 323d1b82bdd..566a17a5e27 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -12,6 +12,7 @@ import { and, eq, inArray } from 'drizzle-orm' import { NextResponse } from 'next/server' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { isAuditLogsEnabled, isBillingEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('V1AuditLogsAuth') @@ -30,7 +31,13 @@ type AuthResult = * Checks: * 1. User belongs to an organization * 2. User has admin or owner role - * 3. Organization has an active enterprise subscription + * 3. The organization is entitled to audit logs — an active enterprise + * subscription when billing runs, otherwise the deployment's audit-logs + * entitlement + * + * The subscription query is skipped entirely with billing off. Requiring it + * there made audit logs unreachable on every self-hosted deployment, since no + * subscription row is ever written without billing. * * Returns the organization ID and all member user IDs on success, * or an error response on failure. @@ -66,36 +73,51 @@ export async function validateEnterpriseAuditAccess( } } - const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) - if (billingBlocked) { + if (isBillingEnabled) { + const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) + if (billingBlocked) { + return { + success: false, + response: NextResponse.json( + { error: 'Active enterprise subscription required' }, + { status: 403 } + ), + } + } + } else if (!isAuditLogsEnabled) { return { success: false, response: NextResponse.json( - { error: 'Active enterprise subscription required' }, + { + error: + 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', + }, { status: 403 } ), } } const [orgSub, orgMembers] = await Promise.all([ - db - .select({ id: subscription.id }) - .from(subscription) - .where( - and( - eq(subscription.referenceId, membership.organizationId), - eq(subscription.plan, 'enterprise'), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1), + isBillingEnabled + ? db + .select({ id: subscription.id }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, membership.organizationId), + eq(subscription.plan, 'enterprise'), + inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) + : Promise.resolve([]), db .select({ userId: member.userId }) .from(member) .where(eq(member.organizationId, membership.organizationId)), ]) - if (orgSub.length === 0) { + if (isBillingEnabled && orgSub.length === 0) { return { success: false, response: NextResponse.json( diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index a567a79e317..b5073b23ab0 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -24,7 +24,17 @@ import { import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { McpIcon } from '@/components/icons' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/env-flags' +import { + isAccessControlEnabled, + isAuditLogsEnabled, + isDataDrainsEnabled, + isDataRetentionEnabled, + isHosted, + isInboxEnabled, + isSessionPoliciesEnabled, + isSsoEnabled, + isWhitelabelingEnabled, +} from '@/lib/core/config/env-flags' export type SettingsPlane = 'account' | 'organization' | 'selfhost' | 'workspace' @@ -166,16 +176,27 @@ export interface SettingsSectionRegistryEntry { planes?: SettingsPlaneProjections } +/** + * Which enterprise sections a self-hosted deployment may show. + * + * These read the same resolved flags the server gates use, so a section is + * visible exactly when its API would accept the request. Reading the raw + * `NEXT_PUBLIC_*` vars here instead is what previously let nav and server + * disagree — a feature could be reachable but hidden, or listed but rejected. + * + * `customBlocks` stays on its own var because its server gate runs through the + * AppConfig-backed feature-flag service rather than the entitlement resolver. + */ const SETTINGS_SELF_HOSTED_OVERRIDES = { - accessControl: isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')), - auditLogs: isTruthy(getEnv('NEXT_PUBLIC_AUDIT_LOGS_ENABLED')), + accessControl: isAccessControlEnabled, + auditLogs: isAuditLogsEnabled, customBlocks: isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED')), - dataDrains: isTruthy(getEnv('NEXT_PUBLIC_DATA_DRAINS_ENABLED')), - dataRetention: isTruthy(getEnv('NEXT_PUBLIC_DATA_RETENTION_ENABLED')), - inbox: isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED')), - sessionPolicies: isTruthy(getEnv('NEXT_PUBLIC_SESSION_POLICIES_ENABLED')), - sso: isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')), - whitelabeling: isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED')), + dataDrains: isDataDrainsEnabled, + dataRetention: isDataRetentionEnabled, + inbox: isInboxEnabled, + sessionPolicies: isSessionPoliciesEnabled, + sso: isSsoEnabled, + whitelabeling: isWhitelabelingEnabled, } as const export const SETTINGS_NAVIGATION_BILLING_ENABLED = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 1281b5e649d..192415b06da 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -1,4 +1,9 @@ import { z } from 'zod' +import { + updateOrganizationDataRetentionBodySchema, + updateOrganizationSessionPolicyBodySchema, + updateOrganizationWhitelabelBodySchema, +} from '@/lib/api/contracts/organization' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { adminV1BooleanQuerySchema, @@ -154,6 +159,32 @@ const adminV1OrganizationBillingUpdateResultSchema = z.object({ orgUsageLimit: z.string().nullable(), }) +/** + * Deleting an organization cascades to its members, invitations, permission + * groups, and settings, and detaches its workspaces (`organization_id` is + * `ON DELETE SET NULL`). Requiring the caller to echo the slug makes that + * irreversible scope an explicit act rather than a mistyped id. + */ +export const adminV1DeleteOrganizationQuerySchema = z.object({ + confirmSlug: z + .string({ error: 'confirmSlug is required and must match the organization slug' }) + .min(1, { error: 'confirmSlug is required and must match the organization slug' }), +}) + +const adminV1DeleteOrganizationResultSchema = z.object({ + success: z.literal(true), + organizationId: z.string(), + slug: z.string(), + membersRemoved: z.number(), + workspacesDetached: z.number(), +}) + +/** Shared result for the org-settings PATCH endpoints. */ +const adminV1OrganizationSettingsResultSchema = z.object({ + success: z.literal(true), + organizationId: z.string(), +}) + const adminV1TransferOwnershipResultSchema = z.object({ organizationId: z.string(), currentOwnerUserId: z.string(), @@ -337,6 +368,62 @@ export type AdminV1UpdateOrganizationBillingResponse = ContractJsonResponse< export type AdminV1GetOrganizationSeatsResponse = ContractJsonResponse< typeof adminV1GetOrganizationSeatsContract > +export const adminV1DeleteOrganizationContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v1/admin/organizations/[id]', + params: adminV1IdParamsSchema, + query: adminV1DeleteOrganizationQuerySchema, + response: { + mode: 'json', + schema: adminV1SingleResponseSchema(adminV1DeleteOrganizationResultSchema), + }, +}) + +export const adminV1UpdateOrganizationWhitelabelContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v1/admin/organizations/[id]/whitelabel', + params: adminV1IdParamsSchema, + body: updateOrganizationWhitelabelBodySchema, + response: { + mode: 'json', + schema: adminV1SingleResponseSchema(adminV1OrganizationSettingsResultSchema), + }, +}) + +export const adminV1UpdateOrganizationDataRetentionContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v1/admin/organizations/[id]/data-retention', + params: adminV1IdParamsSchema, + body: updateOrganizationDataRetentionBodySchema, + response: { + mode: 'json', + schema: adminV1SingleResponseSchema(adminV1OrganizationSettingsResultSchema), + }, +}) + +export const adminV1UpdateOrganizationSessionPolicyContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v1/admin/organizations/[id]/session-policy', + params: adminV1IdParamsSchema, + body: updateOrganizationSessionPolicyBodySchema, + response: { + mode: 'json', + schema: adminV1SingleResponseSchema(adminV1OrganizationSettingsResultSchema), + }, +}) + export type AdminV1TransferOwnershipResponse = ContractJsonResponse< typeof adminV1TransferOwnershipContract > +export type AdminV1DeleteOrganizationResponse = ContractJsonResponse< + typeof adminV1DeleteOrganizationContract +> +export type AdminV1UpdateOrganizationWhitelabelResponse = ContractJsonResponse< + typeof adminV1UpdateOrganizationWhitelabelContract +> +export type AdminV1UpdateOrganizationDataRetentionResponse = ContractJsonResponse< + typeof adminV1UpdateOrganizationDataRetentionContract +> +export type AdminV1UpdateOrganizationSessionPolicyResponse = ContractJsonResponse< + typeof adminV1UpdateOrganizationSessionPolicyContract +> diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 167e02112dd..39a0cf7da55 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -105,6 +105,7 @@ import { import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { joinInstanceOrganization } from '@/lib/organizations/instance-org' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' import { disableUserResources } from '@/lib/workflows/lifecycle' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' @@ -328,6 +329,15 @@ export const auth = betterAuth({ }) } + /** + * Places the user in the instance organization before they reach the + * workspace list, so their first workspace is created org-owned and + * org-scoped enterprise settings apply to it from the start. No-ops + * unless `INSTANCE_ORG_NAME` is set, and swallows its own failures so + * organization setup can never block a signup. + */ + await joinInstanceOrganization(user.id) + if (isHosted && user.email && user.emailVerified) { try { const html = await renderWelcomeEmail(user.name || undefined) @@ -3228,8 +3238,14 @@ export const auth = betterAuth({ }, ], }), - // Include SSO plugin when enabled - ...(env.SSO_ENABLED + /** + * Include SSO plugin when enabled. Resolved through `isSsoEnabled` rather + * than the raw env var so the `ENTERPRISE_ENABLED` suite switch registers + * the plugin too — reading `env.SSO_ENABLED` here would leave the settings + * section visible and `hasSSOAccess` passing while sign-in silently had no + * SSO provider behind it. + */ + ...(isSsoEnabled ? [ sso({ /** diff --git a/apps/sim/lib/auth/session-policy.ts b/apps/sim/lib/auth/session-policy.ts index 484423dfde3..705129af9e7 100644 --- a/apps/sim/lib/auth/session-policy.ts +++ b/apps/sim/lib/auth/session-policy.ts @@ -5,8 +5,8 @@ import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization' import { getMemberOrganizationId, invalidateMembershipCache } from '@/lib/auth/security-policy' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { isSessionPoliciesEnabled } from '@/lib/core/config/env-flags' const logger = createLogger('SessionPolicy') @@ -60,7 +60,7 @@ export async function getSessionPolicy( const settings: SessionPolicySettings = row?.settings ?? {} const hasBounds = Boolean(settings.maxSessionHours || settings.idleTimeoutHours) const isEntitled = - !hasBounds || !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + !hasBounds || (await isOrganizationFeatureEntitled(organizationId, isSessionPoliciesEnabled)) const policy: ResolvedSessionPolicy = isEntitled ? { maxSessionHours: settings.maxSessionHours ?? null, diff --git a/apps/sim/lib/billing/cleanup-dispatcher.test.ts b/apps/sim/lib/billing/cleanup-dispatcher.test.ts index e923da4135a..abda30b3978 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.test.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.test.ts @@ -1,19 +1,34 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsTriggerAvailable } = vi.hoisted(() => ({ +const { mockIsTriggerAvailable, mockGetOrganizationSubscription, mockEnqueue } = vi.hoisted(() => ({ mockIsTriggerAvailable: vi.fn(), + mockGetOrganizationSubscription: vi.fn(), + mockEnqueue: vi.fn(), })) -vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: vi.fn() })) +vi.mock('@/lib/billing/core/billing', () => ({ + getOrganizationSubscription: mockGetOrganizationSubscription, +})) vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPriorityPersonalSubscription: vi.fn(), })) -vi.mock('@/lib/cleanup/batch-delete', () => ({ chunkArray: vi.fn() })) -vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn() })) +vi.mock('@/lib/cleanup/batch-delete', () => ({ + chunkArray: vi.fn((items: unknown[]) => (items.length > 0 ? [items] : [])), +})) +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn(() => ({ enqueue: mockEnqueue })), +})) vi.mock('@/lib/core/async-jobs/config', () => ({ shouldExecuteInline: vi.fn(() => false) })) vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: vi.fn() })) vi.mock('@/lib/knowledge/documents/service', () => ({ @@ -28,22 +43,59 @@ import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' afterAll(resetEnvFlagsMock) -describe('dispatchCleanupJobs billing gate', () => { +describe('dispatchCleanupJobs retention gate', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - setEnvFlags({ isBillingEnabled: false }) + setEnvFlags({ isBillingEnabled: false, isDataRetentionEnabled: false }) + mockIsTriggerAvailable.mockReturnValue(false) + mockGetOrganizationSubscription.mockResolvedValue(null) }) afterAll(() => { resetDbChainMock() }) - it('never dispatches plan-based retention deletion when billing is disabled', async () => { + it('never dispatches retention deletion when billing is disabled and retention is off', async () => { const result = await dispatchCleanupJobs('cleanup-logs') expect(result).toEqual({ jobIds: [], jobCount: 0, chunkCount: 0, workspaceCount: 0 }) expect(mockIsTriggerAvailable).not.toHaveBeenCalled() expect(dbChainMockFns.select).not.toHaveBeenCalled() }) + + it('scans workspaces once retention is explicitly enabled', async () => { + setEnvFlags({ isDataRetentionEnabled: true }) + queueTableRows(schemaMock.workspace, []) + + await dispatchCleanupJobs('cleanup-logs') + + expect(dbChainMockFns.select).toHaveBeenCalled() + }) + + it('deletes nothing for a workspace with no configured retention', async () => { + setEnvFlags({ isDataRetentionEnabled: true }) + /** + * The safety property for self-hosted retention: with billing off every + * workspace resolves as enterprise, which carries no plan default, so a + * workspace whose organization configured nothing keeps its data forever. + * Falling through to the free-tier default would silently expire logs on a + * 30-day window the operator never chose. + */ + queueTableRows(schemaMock.workspace, [ + { + id: 'ws-1', + billedAccountUserId: 'user-1', + organizationId: null, + workspaceMode: 'personal', + organizationSettings: null, + }, + ]) + queueTableRows(schemaMock.workspace, []) + + const result = await dispatchCleanupJobs('cleanup-logs') + + expect(result.workspaceCount).toBe(0) + expect(mockGetOrganizationSubscription).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index 78a97e4dd72..b02baf872e2 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -13,7 +13,7 @@ import { getJobQueue } from '@/lib/core/async-jobs' import { shouldExecuteInline } from '@/lib/core/async-jobs/config' import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import type { EnqueueOptions } from '@/lib/core/async-jobs/types' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isBillingEnabled, isDataRetentionEnabled } from '@/lib/core/config/env-flags' import { isTriggerAvailable } from '@/lib/knowledge/documents/service' import { isOrganizationWorkspace, WORKSPACE_MODE } from '@/lib/workspaces/policy' @@ -137,6 +137,22 @@ async function resolvePersonalPlanTypesByBilledUserId( async function resolvePlanTypesByWorkspaceId( rows: WorkspaceCleanupScopeRow[] ): Promise> { + /** + * Without billing there are no subscription rows to read, and the per-plan + * defaults describe hosted tiers the operator never bought — falling through + * to them would expire logs on a 30-day free-tier window nobody chose. + * + * Classifying every workspace as enterprise gives the semantics a self-hosted + * deployment actually wants: enterprise carries no default, so retention + * comes only from explicitly configured `organization.dataRetentionSettings` + * and a workspace with nothing configured keeps its data forever. It also + * keeps org-owned workspaces in scope, which the subscription lookup below + * would otherwise skip on every billing-free deployment. + */ + if (!isBillingEnabled) { + return new Map(rows.map((row) => [row.id, 'enterprise' as PlanCategory])) + } + const userScopedRows = rows.filter((row) => row.workspaceMode !== WORKSPACE_MODE.ORGANIZATION) const userPlanByBilledUserId = await resolvePersonalPlanTypesByBilledUserId(userScopedRows) const entries = await Promise.all( @@ -302,11 +318,16 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ workspaceCount: number }> { /** - * Plan-based retention is a hosted billing policy. Billing-disabled - * deployments must never delete user data on the hosted defaults. + * Plan-based retention is a hosted billing policy, so a billing-disabled + * deployment must never start expiring data on hosted defaults it never + * chose. Retention is therefore opt-in off-hosted: the operator turns it on + * with `DATA_RETENTION_ENABLED` (or the `ENTERPRISE_ENABLED` suite switch) + * after configuring the windows they want. */ - if (!isBillingEnabled) { - logger.info(`[${jobType}] Skipping cleanup dispatch: billing is disabled`) + if (!isBillingEnabled && !isDataRetentionEnabled) { + logger.info( + `[${jobType}] Skipping cleanup dispatch: billing is disabled and data retention is not enabled` + ) return { jobIds: [], jobCount: 0, chunkCount: 0, workspaceCount: 0 } } diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 5ab950c0eb8..9352d882357 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -453,6 +453,31 @@ export async function isOrganizationOnEnterprisePlan(organizationId: string): Pr } } +/** + * Entitlement for a single org-scoped enterprise feature. + * + * When billing runs, the organization's plan decides and every feature moves + * together. When it does not, there is no plan to read, so deployment + * configuration decides per feature — which is what lets an operator run, say, + * audit logs without whitelabeling. + * + * Pass the matching flag from `@/lib/core/config/env-flags` as + * `selfHostEntitlement`; those already resolve the master switch and the + * feature's legacy default. + * + * Prefer this over calling {@link isOrganizationOnEnterprisePlan} directly in a + * feature gate. That helper is feature-agnostic and answers `true` for + * everything once billing is off, which is exactly the behavior that made + * self-hosted flags meaningless. + */ +export async function isOrganizationFeatureEntitled( + organizationId: string, + selfHostEntitlement: boolean +): Promise { + if (!isBillingEnabled) return selfHostEntitlement + return isOrganizationOnEnterprisePlan(organizationId) +} + /** * Check if user has access to SSO feature * Returns true if: diff --git a/apps/sim/lib/billing/organizations/create-organization.ts b/apps/sim/lib/billing/organizations/create-organization.ts index 2bedcfbc8ee..1947a333745 100644 --- a/apps/sim/lib/billing/organizations/create-organization.ts +++ b/apps/sim/lib/billing/organizations/create-organization.ts @@ -3,6 +3,7 @@ import { member, organization } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, ne } from 'drizzle-orm' import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock' +import type { DbOrTx } from '@/lib/db/types' const ORGANIZATION_SLUG_REGEX = /^[a-z0-9-_]+$/ @@ -62,51 +63,59 @@ export async function ensureOrganizationSlugAvailable({ } } -export async function createOrganizationWithOwner({ - ownerUserId, - name, - slug, - metadata = {}, -}: CreateOrganizationWithOwnerParams): Promise { +export async function createOrganizationWithOwner( + params: CreateOrganizationWithOwnerParams +): Promise { + return db.transaction((tx) => createOrganizationWithOwnerTx(tx, params)) +} + +/** + * Transaction-enlisted organization creation. + * + * `organization.slug` has no unique constraint, so the slug check here is only + * as strong as the transaction it runs in. A caller that needs the check and + * the insert to be atomic against other processes — provisioning a + * well-known slug from several replicas, for instance — must hold its own + * advisory lock for the life of the transaction it passes in. Splitting the + * check and the insert across two transactions allows duplicate slugs. + */ +export async function createOrganizationWithOwnerTx( + tx: DbOrTx, + { ownerUserId, name, slug, metadata = {} }: CreateOrganizationWithOwnerParams +): Promise { validateOrganizationSlugOrThrow(slug) const organizationId = `org_${generateId()}` const memberId = generateId() const now = new Date() - await db.transaction(async (tx) => { - await acquireUserBillingIdentityLock(tx, ownerUserId) - const whereClause = eq(organization.slug, slug) - const existingOrganization = await tx - .select({ id: organization.id }) - .from(organization) - .where(whereClause) - .limit(1) - - if (existingOrganization.length > 0) { - throw new OrganizationSlugTakenError(slug) - } - - await tx.insert(organization).values({ - id: organizationId, - name, - slug, - metadata, - createdAt: now, - updatedAt: now, - }) - - await tx.insert(member).values({ - id: memberId, - userId: ownerUserId, - organizationId, - role: 'owner', - createdAt: now, - }) + await acquireUserBillingIdentityLock(tx, ownerUserId) + const existingOrganization = await tx + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, slug)) + .limit(1) + + if (existingOrganization.length > 0) { + throw new OrganizationSlugTakenError(slug) + } + + await tx.insert(organization).values({ + id: organizationId, + name, + slug, + metadata, + createdAt: now, + updatedAt: now, }) - return { + await tx.insert(member).values({ + id: memberId, + userId: ownerUserId, organizationId, - memberId, - } + role: 'owner', + createdAt: now, + }) + + return { organizationId, memberId } } diff --git a/apps/sim/lib/billing/retention.test.ts b/apps/sim/lib/billing/retention.test.ts index 05409fbbf87..84fca461408 100644 --- a/apps/sim/lib/billing/retention.test.ts +++ b/apps/sim/lib/billing/retention.test.ts @@ -5,6 +5,7 @@ import type { DataRetentionSettings, PiiRedactionRule } from '@sim/db/schema' import { describe, expect, it } from 'vitest' import { DEFAULT_PII_REDACTION, + getPiiRedactionDenialReason, resolveEffectivePiiRedaction, resolveEffectiveRetentionHours, } from '@/lib/billing/retention' @@ -355,3 +356,113 @@ describe('resolveEffectiveRetentionHours', () => { ).toBeNull() }) }) + +describe('getPiiRedactionDenialReason', () => { + const BOTH_ON = { piiRedactionEnabled: true, piiGranularRedactionEnabled: true } + + function rule(workspaceId: string | null, stages?: { input?: boolean; blockOutputs?: boolean }) { + return { + id: `r-${workspaceId ?? 'all'}`, + workspaceId, + stages: stages + ? { + input: { enabled: stages.input === true, entityTypes: [] }, + blockOutputs: { enabled: stages.blockOutputs === true, entityTypes: [] }, + logs: { enabled: false, entityTypes: [] }, + } + : undefined, + } + } + + it('allows the write when both flags are on', () => { + expect( + getPiiRedactionDenialReason({ + current: null, + incoming: { rules: [rule('ws-1', { input: true })] }, + ...BOTH_ON, + }) + ).toBeNull() + }) + + it('rejects any write when PII redaction is off', () => { + expect( + getPiiRedactionDenialReason({ + current: null, + incoming: { rules: [] }, + piiRedactionEnabled: false, + piiGranularRedactionEnabled: true, + }) + ).toContain('PII redaction is not enabled') + }) + + it('rejects newly enabling a granular stage when the granular flag is off', () => { + expect( + getPiiRedactionDenialReason({ + current: null, + incoming: { rules: [rule('ws-1', { input: true })] }, + piiRedactionEnabled: true, + piiGranularRedactionEnabled: false, + }) + ).toContain('Granular PII redaction') + }) + + it('allows re-saving a granular stage that is already enabled', () => { + /** + * The settings UI re-sends the full PII snapshot on every save, so an + * organization that configured granular stages before the flag was turned + * off must still be able to change unrelated retention settings. + */ + expect( + getPiiRedactionDenialReason({ + current: { rules: [rule('ws-1', { input: true })] }, + incoming: { rules: [rule('ws-1', { input: true })] }, + piiRedactionEnabled: true, + piiGranularRedactionEnabled: false, + }) + ).toBeNull() + }) + + it('rejects when one stage is preserved but another is newly enabled', () => { + expect( + getPiiRedactionDenialReason({ + current: { rules: [rule('ws-1', { input: true })] }, + incoming: { rules: [rule('ws-1', { input: true, blockOutputs: true })] }, + piiRedactionEnabled: true, + piiGranularRedactionEnabled: false, + }) + ).toContain('Granular PII redaction') + }) + + it('scopes the comparison per rule target, so another workspace does not grant enablement', () => { + expect( + getPiiRedactionDenialReason({ + current: { rules: [rule('ws-1', { input: true })] }, + incoming: { rules: [rule('ws-2', { input: true })] }, + piiRedactionEnabled: true, + piiGranularRedactionEnabled: false, + }) + ).toContain('Granular PII redaction') + }) + + it('treats the org-default rule (null workspaceId) as its own target', () => { + expect( + getPiiRedactionDenialReason({ + current: { rules: [rule(null, { input: true })] }, + incoming: { rules: [rule(null, { input: true })] }, + piiRedactionEnabled: true, + piiGranularRedactionEnabled: false, + }) + ).toBeNull() + }) + + it('allows a logs-only write while the granular flag is off', () => { + expect( + getPiiRedactionDenialReason({ + current: null, + incoming: { rules: [rule('ws-1', {})] }, + piiRedactionEnabled: true, + piiGranularRedactionEnabled: false, + }) + ).toBeNull() + }) +}) diff --git a/apps/sim/lib/billing/retention.ts b/apps/sim/lib/billing/retention.ts index d36959e4c1c..878940ec12e 100644 --- a/apps/sim/lib/billing/retention.ts +++ b/apps/sim/lib/billing/retention.ts @@ -140,3 +140,77 @@ export function resolveEffectiveRetentionHours(params: { if (overrideValue !== undefined) return overrideValue return params.orgSettings?.[params.key] ?? null } + +/** + * The subset of a PII redaction settings object this gate reads. Both the + * stored `organization.dataRetentionSettings.piiRedaction` and an incoming + * request body satisfy it structurally. + */ +interface PiiRedactionRulesLike { + rules?: Array<{ + workspaceId?: string | null + stages?: { + input?: { enabled?: boolean } | null + blockOutputs?: { enabled?: boolean } | null + } | null + }> | null +} + +/** + * Which granular stages (`input`/`blockOutputs`) are already enabled per rule + * target (`workspaceId ?? ''` = the org default). + */ +function granularStageEnablement( + settings: PiiRedactionRulesLike | null | undefined +): Map { + const map = new Map() + for (const rule of settings?.rules ?? []) { + map.set(rule.workspaceId ?? '', { + input: rule.stages?.input?.enabled === true, + blockOutputs: rule.stages?.blockOutputs?.enabled === true, + }) + } + return map +} + +/** + * Whether a write to `piiRedaction` is permitted, given the deployment's PII + * feature flags. Returns `null` when allowed, otherwise the reason to reject + * with. + * + * The granular check gates *new* enablement only. When + * `pii-granular-redaction` is off, an organization that already configured + * granular stages must still be able to re-save unrelated retention settings — + * the settings UI re-sends the full PII snapshot on every save — so a stage + * that is merely preserved never rejects, only one transitioning off to on. + * + * Pure by design: callers resolve the two flags and pass them in, which keeps + * this module free of the feature-flag service and makes the rule testable + * without mocking it. Shared so the settings API and the Admin API cannot drift + * to different answers for the same write. + */ +export function getPiiRedactionDenialReason(params: { + current: PiiRedactionRulesLike | null | undefined + incoming: PiiRedactionRulesLike | null | undefined + piiRedactionEnabled: boolean + piiGranularRedactionEnabled: boolean +}): string | null { + if (!params.piiRedactionEnabled) { + return 'PII redaction is not enabled for this organization' + } + + if (params.piiGranularRedactionEnabled) return null + + const currentGranular = granularStageEnablement(params.current) + const newlyEnablesGranular = (params.incoming?.rules ?? []).some((rule) => { + const existing = currentGranular.get(rule.workspaceId ?? '') + return ( + (rule.stages?.input?.enabled === true && !existing?.input) || + (rule.stages?.blockOutputs?.enabled === true && !existing?.blockOutputs) + ) + }) + + return newlyEnablesGranular + ? 'Granular PII redaction (workflow input and block outputs) is not enabled for this organization' + : null +} diff --git a/apps/sim/lib/core/config/enterprise-entitlements.test.ts b/apps/sim/lib/core/config/enterprise-entitlements.test.ts new file mode 100644 index 00000000000..67717240540 --- /dev/null +++ b/apps/sim/lib/core/config/enterprise-entitlements.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + ENTERPRISE_FEATURE_LEGACY_DEFAULTS, + type EnterpriseFeature, + resolveEnterpriseEntitlement, +} from '@/lib/core/config/enterprise-entitlements' + +describe('resolveEnterpriseEntitlement', () => { + describe('precedence', () => { + it('uses the feature flag when set, over the master switch', () => { + expect( + resolveEnterpriseEntitlement({ explicit: true, masterEnabled: false, legacyDefault: false }) + ).toBe(true) + }) + + it('honors an explicit false even when the master switch is on', () => { + expect( + resolveEnterpriseEntitlement({ explicit: false, masterEnabled: true, legacyDefault: true }) + ).toBe(false) + }) + + it('falls back to the master switch when the feature flag is unset', () => { + expect( + resolveEnterpriseEntitlement({ + explicit: undefined, + masterEnabled: true, + legacyDefault: false, + }) + ).toBe(true) + }) + + it('falls back to the legacy default when nothing is configured', () => { + expect( + resolveEnterpriseEntitlement({ + explicit: undefined, + masterEnabled: false, + legacyDefault: true, + }) + ).toBe(true) + expect( + resolveEnterpriseEntitlement({ + explicit: undefined, + masterEnabled: false, + legacyDefault: false, + }) + ).toBe(false) + }) + }) + + describe('upgrade guarantee', () => { + const features = Object.keys(ENTERPRISE_FEATURE_LEGACY_DEFAULTS) as EnterpriseFeature[] + + it('reproduces each feature legacy default when nothing is configured', () => { + for (const feature of features) { + expect( + resolveEnterpriseEntitlement({ + explicit: undefined, + masterEnabled: false, + legacyDefault: ENTERPRISE_FEATURE_LEGACY_DEFAULTS[feature], + }) + ).toBe(ENTERPRISE_FEATURE_LEGACY_DEFAULTS[feature]) + } + }) + + it('never turns a feature off that was previously on', () => { + for (const feature of features) { + const legacyDefault = ENTERPRISE_FEATURE_LEGACY_DEFAULTS[feature] + if (!legacyDefault) continue + expect( + resolveEnterpriseEntitlement({ explicit: undefined, masterEnabled: true, legacyDefault }) + ).toBe(true) + } + }) + + it('turns every feature on when the master switch is set', () => { + for (const feature of features) { + expect( + resolveEnterpriseEntitlement({ + explicit: undefined, + masterEnabled: true, + legacyDefault: ENTERPRISE_FEATURE_LEGACY_DEFAULTS[feature], + }) + ).toBe(true) + } + }) + }) + + describe('legacy defaults', () => { + it('keeps the features that were already reachable with billing off', () => { + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.whitelabeling).toBe(true) + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.sessionPolicies).toBe(true) + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.inbox).toBe(true) + }) + + it('keeps destructive and previously unreachable features opt-in', () => { + /** + * Retention deletes data and audit logs could not be reached at all + * before, so neither may switch on merely because a deployment upgraded. + */ + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.dataRetention).toBe(false) + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.auditLogs).toBe(false) + }) + + it('keeps the features that were closed behind their own flag closed', () => { + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.dataDrains).toBe(false) + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.forking).toBe(false) + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.accessControl).toBe(false) + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.organizations).toBe(false) + expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.sso).toBe(false) + }) + }) +}) diff --git a/apps/sim/lib/core/config/enterprise-entitlements.ts b/apps/sim/lib/core/config/enterprise-entitlements.ts new file mode 100644 index 00000000000..ea82fea86c4 --- /dev/null +++ b/apps/sim/lib/core/config/enterprise-entitlements.ts @@ -0,0 +1,96 @@ +/** + * Resolution rules for enterprise features on deployments that do not run + * billing. + * + * On Sim Cloud, entitlement comes from the subscription plan. Self-hosted + * deployments have no subscription, so entitlement comes from environment + * configuration instead. Historically each feature invented its own rule: some + * were closed until their flag was set, others were implicitly open whenever + * billing was disabled, and three flags were never read at all. This module is + * the single place those rules live so they cannot drift apart again. + * + * Precedence, highest first: + * 1. The feature's own flag, when set to any recognized value. Setting it to + * `false` is meaningful — it turns one feature off inside an otherwise + * enabled suite. + * 2. `ENTERPRISE_ENABLED`, the master switch that turns the suite on. + * 3. The feature's legacy default, which reproduces the behavior the + * deployment had before the master switch existed. + * + * Step 3 is what makes upgrades safe: an operator who never sets + * `ENTERPRISE_ENABLED` keeps exactly the features they had. + * + * Kept free of imports from `./env-flags` so that module can build its exported + * flags on top of this one without a cycle. + */ + +/** Enterprise features whose self-hosted availability is configuration-driven. */ +export type EnterpriseFeature = + | 'accessControl' + | 'auditLogs' + | 'dataDrains' + | 'dataRetention' + | 'forking' + | 'inbox' + | 'organizations' + | 'sessionPolicies' + | 'sso' + | 'whitelabeling' + +/** + * What each feature resolved to before `ENTERPRISE_ENABLED` existed, for a + * deployment with billing off and no per-feature flag set. + * + * `true` means the feature was already reachable, because its server gate only + * asked whether billing was disabled — `isOrganizationOnEnterprisePlan` returns + * true when billing is off. `false` means the gate was closed until the flag + * was set. + * + * Two entries are deliberately `false` even though a case could be made for + * `true`: + * + * - `auditLogs` was unreachable self-hosted at any flag setting, because the + * gate demanded a subscription row that never exists without billing. It is + * a new capability here, so it stays opt-in rather than appearing + * unannounced. + * - `dataRetention` gates retention *deletion*, which destroys data. Config + * was always writable when billing was off and stays that way; only the + * delete pass is gated here. Defaulting it on would start expiring logs on + * upgrade against plan defaults the operator never chose. + * + * Do not "tidy" these to a uniform value. Each records observed prior behavior, + * and changing one silently alters a live deployment on upgrade. + */ +export const ENTERPRISE_FEATURE_LEGACY_DEFAULTS: Readonly> = { + accessControl: false, + auditLogs: false, + dataDrains: false, + dataRetention: false, + forking: false, + inbox: true, + organizations: false, + sessionPolicies: true, + sso: false, + whitelabeling: true, +} as const + +interface ResolveEnterpriseEntitlementParams { + /** The feature's own flag: `undefined` when unset, otherwise the operator's choice. */ + explicit: boolean | undefined + /** Whether `ENTERPRISE_ENABLED` (or its client twin) is set. */ + masterEnabled: boolean + /** The feature's entry in {@link ENTERPRISE_FEATURE_LEGACY_DEFAULTS}. */ + legacyDefault: boolean +} + +/** + * Applies the precedence described above. Pure and synchronous so gates can + * call it at module scope. + */ +export function resolveEnterpriseEntitlement({ + explicit, + masterEnabled, + legacyDefault, +}: ResolveEnterpriseEntitlementParams): boolean { + return explicit ?? (masterEnabled || legacyDefault) +} diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 23960cea260..1345b72c057 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -1,7 +1,12 @@ /** * Environment utility functions for consistent environment detection across the application */ -import { env, getEnv, isFalsy, isTruthy } from './env' +import { + ENTERPRISE_FEATURE_LEGACY_DEFAULTS, + type EnterpriseFeature, + resolveEnterpriseEntitlement, +} from './enterprise-entitlements' +import { env, envBoolean, getEnv, isFalsy, isTruthy } from './env' /** * Is the application running in production mode @@ -172,26 +177,78 @@ export const isSlackExtendedScopesEnabled = export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) /** - * Is SSO enabled for enterprise authentication + * Turns on the whole enterprise suite for a deployment that does not run + * billing. Individual feature flags below still win where they are set, so an + * operator can enable everything and then switch one feature back off. + * + * Server code reads `ENTERPRISE_ENABLED`; the browser reads the + * `NEXT_PUBLIC_ENTERPRISE_ENABLED` twin (see {@link isBillingEnabled}). + * Deployments must set both together. */ -export const isSsoEnabled = isTruthy(env.SSO_ENABLED) +export const isEnterpriseEnabled = + typeof window === 'undefined' + ? isTruthy(env.ENTERPRISE_ENABLED) + : isTruthy(getEnv('NEXT_PUBLIC_ENTERPRISE_ENABLED')) + +/** + * Reads a feature's own flag as a tri-state, picking the server var or its + * browser twin for the current runtime. `undefined` means the operator left it + * unset, which is what lets the master switch and legacy default apply. + */ +function explicitEnterpriseFlag( + serverValue: boolean | string | undefined, + clientKey: string +): boolean | undefined { + return typeof window === 'undefined' ? envBoolean(serverValue) : envBoolean(getEnv(clientKey)) +} /** - * Is access control (permission groups) enabled via env var override. - * This bypasses plan requirements for self-hosted deployments. + * Resolves one enterprise feature for this deployment. * - * Server code reads `ACCESS_CONTROL_ENABLED`; the browser reads the - * `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` twin (see {@link isBillingEnabled}). + * When billing runs, subscription plans decide entitlement and these flags are + * only explicit overrides — so an unset flag stays `false` and never widens + * access on Sim Cloud. When billing is off there is no plan to consult, so + * resolution falls through the master switch to the feature's legacy default + * (see {@link ENTERPRISE_FEATURE_LEGACY_DEFAULTS}). + */ +function enterpriseFeatureEnabled( + feature: EnterpriseFeature, + serverValue: boolean | string | undefined, + clientKey: string +): boolean { + const explicit = explicitEnterpriseFlag(serverValue, clientKey) + if (isBillingEnabled) return explicit ?? false + return resolveEnterpriseEntitlement({ + explicit, + masterEnabled: isEnterpriseEnabled, + legacyDefault: ENTERPRISE_FEATURE_LEGACY_DEFAULTS[feature], + }) +} + +/** + * Is SSO enabled for enterprise authentication */ -export const isAccessControlEnabled = - typeof window === 'undefined' - ? isTruthy(env.ACCESS_CONTROL_ENABLED) - : isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')) +export const isSsoEnabled = enterpriseFeatureEnabled( + 'sso', + env.SSO_ENABLED, + 'NEXT_PUBLIC_SSO_ENABLED' +) + +/** + * Is access control (permission groups) enabled. + * Required for permission-group enforcement to run at all off-hosted. + */ +export const isAccessControlEnabled = enterpriseFeatureEnabled( + 'accessControl', + env.ACCESS_CONTROL_ENABLED, + 'NEXT_PUBLIC_ACCESS_CONTROL_ENABLED' +) /** * Is organizations enabled. - * True if billing is enabled (orgs come with billing), OR explicitly enabled via env var, - * OR if access control is enabled (access control requires organizations). + * True if billing is enabled (orgs come with billing), OR resolved on for this + * deployment, OR if access control is enabled (access control requires + * organizations). * * Each term resolves through its `NEXT_PUBLIC_*` twin in the browser (see * {@link isBillingEnabled}), so client code — e.g. the better-auth @@ -199,46 +256,82 @@ export const isAccessControlEnabled = */ export const isOrganizationsEnabled = isBillingEnabled || - (typeof window === 'undefined' - ? isTruthy(env.ORGANIZATIONS_ENABLED) - : isTruthy(getEnv('NEXT_PUBLIC_ORGANIZATIONS_ENABLED'))) || + enterpriseFeatureEnabled( + 'organizations', + env.ORGANIZATIONS_ENABLED, + 'NEXT_PUBLIC_ORGANIZATIONS_ENABLED' + ) || isAccessControlEnabled /** - * Is inbox (Sim Mailer) enabled via env var override - * This bypasses hosted requirements for self-hosted deployments + * Is inbox (Sim Mailer) enabled */ -export const isInboxEnabled = isTruthy(env.INBOX_ENABLED) +export const isInboxEnabled = enterpriseFeatureEnabled( + 'inbox', + env.INBOX_ENABLED, + 'NEXT_PUBLIC_INBOX_ENABLED' +) /** - * Is whitelabeling enabled via env var override - * This bypasses hosted requirements for self-hosted deployments + * Is whitelabeling enabled */ -export const isWhitelabelingEnabled = isTruthy(env.WHITELABELING_ENABLED) +export const isWhitelabelingEnabled = enterpriseFeatureEnabled( + 'whitelabeling', + env.WHITELABELING_ENABLED, + 'NEXT_PUBLIC_WHITELABELING_ENABLED' +) /** - * Is audit logs enabled via env var override - * This bypasses hosted requirements for self-hosted deployments + * Is audit log reading enabled. + * + * Off-hosted this replaces the enterprise-subscription check that audit access + * used to require, which no billing-free deployment could ever satisfy. */ -export const isAuditLogsEnabled = isTruthy(env.AUDIT_LOGS_ENABLED) +export const isAuditLogsEnabled = enterpriseFeatureEnabled( + 'auditLogs', + env.AUDIT_LOGS_ENABLED, + 'NEXT_PUBLIC_AUDIT_LOGS_ENABLED' +) /** - * Is data retention enabled via env var override - * This bypasses hosted requirements for self-hosted deployments + * Is retention *deletion* enabled. + * + * Configuring retention has always been possible with billing off; this flag + * governs whether the cleanup pass actually expires data. Opt-in on purpose — + * see the note on `dataRetention` in {@link ENTERPRISE_FEATURE_LEGACY_DEFAULTS}. + */ +export const isDataRetentionEnabled = enterpriseFeatureEnabled( + 'dataRetention', + env.DATA_RETENTION_ENABLED, + 'NEXT_PUBLIC_DATA_RETENTION_ENABLED' +) + +/** + * Is data drains enabled */ -export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED) +export const isDataDrainsEnabled = enterpriseFeatureEnabled( + 'dataDrains', + env.DATA_DRAINS_ENABLED, + 'NEXT_PUBLIC_DATA_DRAINS_ENABLED' +) /** - * Is data drains enabled via env var override - * This bypasses hosted requirements for self-hosted deployments + * Are organization session policies enabled */ -export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED) +export const isSessionPoliciesEnabled = enterpriseFeatureEnabled( + 'sessionPolicies', + env.SESSION_POLICIES_ENABLED, + 'NEXT_PUBLIC_SESSION_POLICIES_ENABLED' +) /** - * Is workspace forking enabled via env var override - * This bypasses hosted (Enterprise) requirements for self-hosted deployments + * Is workspace forking enabled */ -export const isForkingEnabled = isTruthy(env.FORKING_ENABLED) +export const isForkingEnabled = enterpriseFeatureEnabled( + 'forking', + env.FORKING_ENABLED, + 'NEXT_PUBLIC_FORKING_ENABLED' +) /** * The selected remote sandbox provider (`SANDBOX_PROVIDER`), defaulting to E2B. diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a97d6854e33..7afd1fb7480 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -467,11 +467,15 @@ export const env = createEnv({ // Access Control (Permission Groups) - for self-hosted deployments ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements) + // Enterprise master switch - for self-hosted deployments + ENTERPRISE_ENABLED: z.boolean().optional(), // Enable the whole enterprise suite on self-hosted; individual flags below override it per feature + // Enterprise Feature Overrides - for self-hosted deployments WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements) AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements) - DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements) + DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings and retention deletion on self-hosted (bypasses hosted requirements) DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements) + SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements) FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) DEPLOY_AS_BLOCK: z.boolean().optional(), // Enable deploy-as-block (publish a workflow as a reusable org-wide custom block) TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles) @@ -479,6 +483,11 @@ export const env = createEnv({ // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) + // Instance-tier organization - every user auto-joins this one org + INSTANCE_ORG_NAME: z.string().min(1).optional(), // Display name of the instance organization; setting it turns instance-org mode on + INSTANCE_ORG_SLUG: z.string().min(1).optional(), // Slug for the instance organization (derived from the name when omitted) + INSTANCE_ORG_OWNER_EMAIL: z.string().min(1).optional(), // Email of the user who owns the instance organization (defaults to the first user who triggers provisioning) + // Invitations - for self-hosted deployments DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access globally (for self-hosted deployments) @@ -570,6 +579,7 @@ export const env = createEnv({ NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Brand background color (hex format) // Feature Flags + NEXT_PUBLIC_ENTERPRISE_ENABLED: z.boolean().optional(), // Client twin of ENTERPRISE_ENABLED — set both together NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted NEXT_PUBLIC_SLACK_EXTENDED_SCOPES: z.boolean().optional(), // Client twin of SLACK_EXTENDED_SCOPES — set both together @@ -623,6 +633,7 @@ export const env = createEnv({ NEXT_PUBLIC_SESSION_POLICIES_ENABLED: process.env.NEXT_PUBLIC_SESSION_POLICIES_ENABLED, NEXT_PUBLIC_FORKING_ENABLED: process.env.NEXT_PUBLIC_FORKING_ENABLED, NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: process.env.NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED, + NEXT_PUBLIC_ENTERPRISE_ENABLED: process.env.NEXT_PUBLIC_ENTERPRISE_ENABLED, NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED, NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS, NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, diff --git a/apps/sim/lib/organizations/instance-org.test.ts b/apps/sim/lib/organizations/instance-org.test.ts new file mode 100644 index 00000000000..c3cc3623907 --- /dev/null +++ b/apps/sim/lib/organizations/instance-org.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreateOrganizationWithOwnerTx, mockEnsureUserInOrganization, mockSelect, mockExecute } = + vi.hoisted(() => ({ + mockCreateOrganizationWithOwnerTx: vi.fn(), + mockEnsureUserInOrganization: vi.fn(), + mockSelect: vi.fn(), + mockExecute: vi.fn(), + })) + +/** + * Minimal chainable stub: each `select()` resolves to the next queued row set, + * which is all these tests need to steer the slug lookup and membership check. + */ +const queuedRows: unknown[][] = [] + +function queueRows(rows: unknown[]): void { + queuedRows.push(rows) +} + +function buildSelectChain() { + const chain: Record = {} + const step = () => chain + for (const method of ['from', 'where', 'innerJoin', 'leftJoin', 'orderBy']) { + chain[method] = vi.fn(step) + } + chain.limit = vi.fn(() => Promise.resolve(queuedRows.shift() ?? [])) + return chain +} + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + execute: mockExecute, + transaction: vi.fn(async (fn: (tx: unknown) => unknown) => + fn({ select: mockSelect, execute: mockExecute }) + ), + }, +})) + +vi.mock('@/lib/billing/organizations/create-organization', () => ({ + createOrganizationWithOwnerTx: mockCreateOrganizationWithOwnerTx, + validateOrganizationSlugOrThrow: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + ensureUserInOrganization: mockEnsureUserInOrganization, +})) + +import { + ensureInstanceOrganization, + getInstanceOrganizationConfig, + isInstanceOrganizationMode, + joinInstanceOrganization, + resetInstanceOrganizationCache, +} from '@/lib/organizations/instance-org' + +const ORIGINAL_ENV = { ...process.env } + +function setInstanceEnv(values: Record): void { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +} + +describe('instance organization', () => { + beforeEach(() => { + vi.clearAllMocks() + queuedRows.length = 0 + resetInstanceOrganizationCache() + setEnvFlags({ isBillingEnabled: false }) + mockSelect.mockImplementation(buildSelectChain) + mockExecute.mockResolvedValue(undefined) + setInstanceEnv({ + INSTANCE_ORG_NAME: 'Acme Inc', + INSTANCE_ORG_SLUG: undefined, + INSTANCE_ORG_OWNER_EMAIL: undefined, + }) + }) + + afterEach(() => { + process.env = { ...ORIGINAL_ENV } + }) + + afterAll(resetEnvFlagsMock) + + describe('configuration', () => { + it('is off when INSTANCE_ORG_NAME is unset', () => { + setInstanceEnv({ INSTANCE_ORG_NAME: undefined }) + expect(getInstanceOrganizationConfig()).toBeNull() + expect(isInstanceOrganizationMode()).toBe(false) + }) + + it('derives a slug from the name', () => { + expect(getInstanceOrganizationConfig()).toMatchObject({ + name: 'Acme Inc', + slug: 'acme-inc', + }) + }) + + it('prefers an explicit slug', () => { + setInstanceEnv({ INSTANCE_ORG_SLUG: 'custom-slug' }) + expect(getInstanceOrganizationConfig()?.slug).toBe('custom-slug') + }) + + it('stays off when billing is enabled, so paid orgs keep their own lifecycle', () => { + setEnvFlags({ isBillingEnabled: true }) + expect(getInstanceOrganizationConfig()).toBeNull() + }) + }) + + describe('provisioning', () => { + it('creates the organization when none exists', async () => { + queueRows([]) // getInstanceOrganizationId lookup + queueRows([]) // post-lock re-check + queueRows([]) // owner membership check + mockCreateOrganizationWithOwnerTx.mockResolvedValue({ organizationId: 'org_new' }) + + const result = await ensureInstanceOrganization('user-1') + + expect(result).toBe('org_new') + /** + * The transaction is passed through so the advisory lock taken above + * still covers the insert. + */ + expect(mockCreateOrganizationWithOwnerTx).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ ownerUserId: 'user-1', name: 'Acme Inc', slug: 'acme-inc' }) + ) + }) + + it('reuses the existing organization without creating a second one', async () => { + queueRows([{ id: 'org_existing' }]) + + const result = await ensureInstanceOrganization('user-1') + + expect(result).toBe('org_existing') + expect(mockCreateOrganizationWithOwnerTx).not.toHaveBeenCalled() + }) + + it('takes the advisory lock before creating', async () => { + queueRows([]) + queueRows([]) + queueRows([]) + mockCreateOrganizationWithOwnerTx.mockResolvedValue({ organizationId: 'org_new' }) + + await ensureInstanceOrganization('user-1') + + const executed = mockExecute.mock.calls.map(([arg]) => JSON.stringify(arg)) + expect(executed.some((sql) => sql.includes('pg_advisory_xact_lock'))).toBe(true) + }) + + it('adopts the organization a racing replica created under the lock', async () => { + queueRows([]) // first lookup misses + queueRows([{ id: 'org_raced' }]) // re-check under the lock finds it + + const result = await ensureInstanceOrganization('user-1') + + expect(result).toBe('org_raced') + expect(mockCreateOrganizationWithOwnerTx).not.toHaveBeenCalled() + }) + + it('refuses when the prospective owner already belongs to another organization', async () => { + queueRows([]) + queueRows([]) + queueRows([{ organizationId: 'org_other' }]) + + const result = await ensureInstanceOrganization('user-1') + + expect(result).toBeNull() + expect(mockCreateOrganizationWithOwnerTx).not.toHaveBeenCalled() + }) + + it('caches the id so repeat calls do not re-query', async () => { + queueRows([{ id: 'org_existing' }]) + await ensureInstanceOrganization('user-1') + const callsAfterFirst = mockSelect.mock.calls.length + + await ensureInstanceOrganization('user-2') + + expect(mockSelect.mock.calls.length).toBe(callsAfterFirst) + }) + }) + + describe('joining', () => { + it('adds the user as a member', async () => { + queueRows([{ id: 'org_existing' }]) + mockEnsureUserInOrganization.mockResolvedValue({ success: true, alreadyMember: false }) + + await joinInstanceOrganization('user-2') + + expect(mockEnsureUserInOrganization).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-2', + organizationId: 'org_existing', + role: 'member', + skipBillingLogic: true, + skipSeatValidation: true, + }) + ) + }) + + it('does nothing when the mode is off', async () => { + setInstanceEnv({ INSTANCE_ORG_NAME: undefined }) + + await joinInstanceOrganization('user-2') + + expect(mockEnsureUserInOrganization).not.toHaveBeenCalled() + }) + + it('never throws, so a failure cannot block signup', async () => { + queueRows([{ id: 'org_existing' }]) + mockEnsureUserInOrganization.mockRejectedValue(new Error('database unavailable')) + + await expect(joinInstanceOrganization('user-2')).resolves.toBeUndefined() + }) + }) +}) diff --git a/apps/sim/lib/organizations/instance-org.ts b/apps/sim/lib/organizations/instance-org.ts new file mode 100644 index 00000000000..5c8e31bbf90 --- /dev/null +++ b/apps/sim/lib/organizations/instance-org.ts @@ -0,0 +1,295 @@ +/** + * Instance-tier organization: one organization that every user on a deployment + * belongs to. + * + * Org-scoped enterprise features — whitelabeling, PII redaction, permission + * groups, data drains, audit scoping — resolve their settings from a + * workspace's `organizationId`. A deployment where everyone works in personal + * workspaces has no organization for those features to read, so enabling them + * appears to do nothing. Setting `INSTANCE_ORG_NAME` turns on this mode: the + * organization is provisioned on first use, every new user joins it, and their + * workspaces are created org-owned. + * + * Only meaningful without billing. With billing on, organizations are created + * and paid for through the normal subscription flow, so this module stays + * inert. + */ + +import { db } from '@sim/db' +import { member, organization, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq, sql } from 'drizzle-orm' +import { + createOrganizationWithOwnerTx, + validateOrganizationSlugOrThrow, +} from '@/lib/billing/organizations/create-organization' +import { env } from '@/lib/core/config/env' +import { isBillingEnabled } from '@/lib/core/config/env-flags' + +const logger = createLogger('InstanceOrganization') + +/** Bounds the wait for a concurrent provisioning attempt on another replica. */ +const INSTANCE_ORG_LOCK_TIMEOUT_MS = 10_000 + +/** + * Once provisioned, the instance organization's id never changes, so it is + * cached for the life of the process. `null` is deliberately not cached — a + * miss means provisioning has not happened yet, and the next caller should + * retry rather than be told "no org" forever. + */ +let cachedInstanceOrganizationId: string | null = null + +/** Derives a slug the same way the admin organization API does. */ +function slugifyOrganizationName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + +interface InstanceOrganizationConfig { + name: string + slug: string + ownerEmail: string | null +} + +/** + * Reads instance-org configuration from the environment, or `null` when the + * mode is off. + * + * Returns `null` when billing is enabled: paid organizations own their own + * lifecycle, and silently folding every signup into one org would break + * per-organization billing. + */ +export function getInstanceOrganizationConfig(): InstanceOrganizationConfig | null { + if (isBillingEnabled) return null + + const name = env.INSTANCE_ORG_NAME?.trim() + if (!name) return null + + const slug = env.INSTANCE_ORG_SLUG?.trim() || slugifyOrganizationName(name) + if (!slug) { + logger.error('INSTANCE_ORG_NAME does not yield a usable slug; set INSTANCE_ORG_SLUG', { name }) + return null + } + + try { + validateOrganizationSlugOrThrow(slug) + } catch { + logger.error( + 'Instance organization slug is invalid. Use lowercase letters, numbers, "-", and "_".', + { slug } + ) + return null + } + + return { name, slug, ownerEmail: env.INSTANCE_ORG_OWNER_EMAIL?.trim() || null } +} + +/** Whether this deployment runs in instance-organization mode. */ +export function isInstanceOrganizationMode(): boolean { + return getInstanceOrganizationConfig() !== null +} + +/** + * Returns the instance organization's id without creating it, or `null` when + * the mode is off or provisioning has not run yet. + */ +export async function getInstanceOrganizationId(): Promise { + if (cachedInstanceOrganizationId) return cachedInstanceOrganizationId + + const config = getInstanceOrganizationConfig() + if (!config) return null + + const [row] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, config.slug)) + .limit(1) + + if (row) cachedInstanceOrganizationId = row.id + return row?.id ?? null +} + +/** + * Picks the user who will own the instance organization. + * + * `INSTANCE_ORG_OWNER_EMAIL` wins when it names an existing user. Otherwise the + * user who triggered provisioning takes ownership, which on a fresh deployment + * is whoever signs up first. Ownership can be moved later through + * `POST /api/v1/admin/organizations/[id]/transfer-ownership`. + */ +async function resolveOwnerUserId( + config: InstanceOrganizationConfig, + fallbackUserId: string +): Promise { + if (!config.ownerEmail) return fallbackUserId + + const [owner] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, config.ownerEmail)) + .limit(1) + + if (owner) return owner.id + + logger.warn( + 'INSTANCE_ORG_OWNER_EMAIL does not match any user yet; assigning ownership to the provisioning user instead', + { ownerEmail: config.ownerEmail } + ) + return fallbackUserId +} + +/** + * Returns the instance organization, creating it if this is the first call. + * + * Idempotent and safe to call concurrently: creation runs under a + * transaction-scoped advisory lock keyed on the slug, and the row is re-checked + * after the lock is held, so two replicas racing on the first signup produce + * one organization rather than two or a unique-violation crash. + * + * Returns `null` when the mode is off, or when provisioning failed — callers + * treat that as "no instance org" and carry on rather than failing the signup + * that triggered it. + */ +export async function ensureInstanceOrganization( + provisioningUserId: string +): Promise { + const config = getInstanceOrganizationConfig() + if (!config) return null + + const existing = await getInstanceOrganizationId() + if (existing) return existing + + try { + const ownerUserId = await resolveOwnerUserId(config, provisioningUserId) + + /** + * The re-check and the insert share one transaction so the advisory lock + * covers both. `pg_advisory_xact_lock` releases at commit, so checking in + * one transaction and creating in the next would leave a window where two + * replicas each see no organization and each create one — and + * `organization.slug` has no unique constraint to catch the duplicate. + */ + const organizationId = await db.transaction(async (tx) => { + await tx.execute( + sql`select set_config('lock_timeout', ${`${INSTANCE_ORG_LOCK_TIMEOUT_MS}ms`}, true)` + ) + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`instance-organization:${config.slug}`}, 0))` + ) + + const [row] = await tx + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, config.slug)) + .limit(1) + if (row) return row.id + + /** + * The owner must not already belong to another organization — a user can + * hold only one membership, so provisioning would fail on the member + * insert. Surface it as a configuration problem instead. + */ + const [ownerMembership] = await tx + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, ownerUserId)) + .limit(1) + + if (ownerMembership) { + logger.error( + 'Cannot provision the instance organization: its owner already belongs to another organization. Move them out, or point INSTANCE_ORG_SLUG at that organization.', + { ownerUserId, existingOrganizationId: ownerMembership.organizationId, slug: config.slug } + ) + return null + } + + const created = await createOrganizationWithOwnerTx(tx, { + ownerUserId, + name: config.name, + slug: config.slug, + metadata: { instanceOrganization: true }, + }) + + logger.info('Provisioned the instance organization', { + organizationId: created.organizationId, + slug: config.slug, + ownerUserId, + }) + return created.organizationId + }) + + if (organizationId) cachedInstanceOrganizationId = organizationId + return organizationId + } catch (error) { + /** + * A slug collision means a concurrent replica won the race between our + * lock release and insert; re-read rather than treating it as a failure. + */ + const resolved = await getInstanceOrganizationId().catch(() => null) + if (resolved) return resolved + + logger.error('Failed to provision the instance organization', { + slug: config.slug, + error: getErrorMessage(error), + }) + return null + } +} + +/** + * Adds a user to the instance organization, provisioning it if needed. + * + * Called from the signup hook, so it never throws: a deployment must not become + * unable to register users because organization setup hit a problem. A user who + * misses the join keeps working in a personal workspace and can be picked up + * later by `apps/sim/scripts/consolidate-users-into-organization.ts`. + * + * No-ops when the mode is off or the user is already a member — which is the + * case for the owner, whose membership is written during provisioning. + * + * The membership module is imported lazily to keep the organization and billing + * graph out of the auth module's load path. + */ +export async function joinInstanceOrganization(userId: string): Promise { + if (!isInstanceOrganizationMode()) return + + try { + const organizationId = await ensureInstanceOrganization(userId) + if (!organizationId) return + + const { ensureUserInOrganization } = await import('@/lib/billing/organizations/membership') + const result = await ensureUserInOrganization({ + userId, + organizationId, + role: 'member', + skipBillingLogic: true, + skipSeatValidation: true, + }) + + if (!result.success) { + logger.error('Failed to add user to the instance organization', { + userId, + organizationId, + reason: result.error, + }) + return + } + + if (!result.alreadyMember) { + logger.info('Added user to the instance organization', { userId, organizationId }) + } + } catch (error) { + logger.error('Failed to add user to the instance organization', { + userId, + error: getErrorMessage(error), + }) + } +} + +/** Clears the cached id. Exported for tests. */ +export function resetInstanceOrganizationCache(): void { + cachedInstanceOrganizationId = null +} diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 0a3ca8d6e55..7378741cf05 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -203,7 +203,32 @@ describe('getWorkspaceCreationPolicy', () => { expect(mockGetOrganizationSubscription).not.toHaveBeenCalled() }) - it('blocks non-admin org members from creating organization workspaces', async () => { + it('allows plain org members to create organization workspaces when billing is disabled', async () => { + setEnvFlags({ isBillingEnabled: false }) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + queueTableRows(member, [{ userId: 'owner-1' }]) + + const result = await getWorkspaceCreationPolicy({ + userId: 'user-1', + activeOrganizationId: 'org-1', + }) + + /** + * Auto-joined users — instance-organization mode, or SSO organization + * provisioning — land here as plain members. Refusing them would leave them + * with no workspace at all, not merely a personal one. + */ + expect(result.canCreate).toBe(true) + expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION) + expect(result.organizationId).toBe('org-1') + expect(result.billedAccountUserId).toBe('owner-1') + }) + + it('still blocks non-admin org members when billing is enabled', async () => { mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member', diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 5ad280e28b5..2fc4bab89b8 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -290,19 +290,19 @@ export async function getWorkspaceCreationPolicy({ if (organizationId && orgRole) { const billedAccountUserId = await requireOrganizationOwnerId(organizationId) - if (!isOrgAdminRole(orgRole)) { - return { - canCreate: false, - workspaceMode: WORKSPACE_MODE.ORGANIZATION, - organizationId, - billedAccountUserId, - maxWorkspaces: null, - currentWorkspaceCount: 0, - reason: 'Only organization owners and admins can create organization workspaces.', - status: 403, - } - } - + /** + * Members may create organization workspaces once billing is off. + * + * The admin-only rule exists because an organization workspace draws on + * the organization's paid seats and usage. Without billing there is + * nothing to draw on, and the rule instead produces a dead end: a user + * auto-joined as a plain member — by instance-organization mode, or by + * SSO organization provisioning — resolves to an organization context and + * is then refused any workspace at all, including the personal one they + * would have received before joining. Members could always create + * personal workspaces here, so this changes where a new workspace lands, + * not whether they may make one. + */ return { canCreate: true, workspaceMode: WORKSPACE_MODE.ORGANIZATION, diff --git a/apps/sim/package.json b/apps/sim/package.json index 09b0780b222..42fb1abb6d4 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -31,7 +31,8 @@ "format": "biome format --write .", "format:check": "biome format .", "generate:pi-model-catalog": "bun run scripts/generate-pi-model-catalog.ts", - "generate-docs": "bun run ../../scripts/generate-docs.ts" + "generate-docs": "bun run ../../scripts/generate-docs.ts", + "org:consolidate-users": "bun run scripts/consolidate-users-into-organization.ts" }, "dependencies": { "@1password/sdk": "0.3.1", diff --git a/apps/sim/scripts/consolidate-users-into-organization.ts b/apps/sim/scripts/consolidate-users-into-organization.ts new file mode 100644 index 00000000000..c70dd4209e8 --- /dev/null +++ b/apps/sim/scripts/consolidate-users-into-organization.ts @@ -0,0 +1,569 @@ +#!/usr/bin/env bun + +/** + * Consolidates every user on a self-hosted deployment into one organization so + * org-scoped enterprise features apply deployment-wide. + * + * Membership alone is not enough. Session policies and SSO provisioning resolve + * the governing org from the user's `member` row, but whitelabeling, PII + * redaction, permission groups, data drains, and audit scoping all resolve it + * from `workspace.organization_id`. This script therefore does both: it adds + * every user to the target org AND attaches their personal/grandfathered + * workspaces to it. + * + * All writes go through the same helpers the product uses + * (`createOrganizationWithOwner`, `ensureUserInOrganization`, + * `attachOwnedWorkspacesToOrganization`), so the storage ledger, billed-account + * routing, owner permissions, and advisory locks stay consistent. It does not + * hand-roll SQL against `member` or `workspace`. + * + * Dry run by default — pass `--apply` to write. + * + * This is a one-time backfill for users and workspaces that predate + * instance-organization mode. Once `INSTANCE_ORG_NAME` is set, new users join + * the organization at signup and their workspaces are created org-owned, so + * the script does not need to run again. With `INSTANCE_ORG_NAME` set it needs + * no arguments at all — it targets that organization by default. + * + * Usage: + * # Backfill into the configured instance organization + * DATABASE_URL=... INSTANCE_ORG_NAME="Acme Inc" \ + * bun run apps/sim/scripts/consolidate-users-into-organization.ts --apply + * + * # Preview, creating a new org + * DATABASE_URL=... bun run apps/sim/scripts/consolidate-users-into-organization.ts \ + * --org-name "Acme Inc" --owner-email admin@acme.com + * + * # Execute + * DATABASE_URL=... bun run apps/sim/scripts/consolidate-users-into-organization.ts \ + * --org-name "Acme Inc" --owner-email admin@acme.com --apply + * + * # Consolidate into an org that already exists + * DATABASE_URL=... bun run apps/sim/scripts/consolidate-users-into-organization.ts \ + * --org-id org_2f1c... --apply + * + * Options: + * --org-id Consolidate into this existing organization. + * --org-slug Consolidate into the organization with this slug. + * --org-name Organization name; created if no match exists. + * --owner-email Owner of the organization. Required when creating. + * --exclude-emails Comma-separated emails to leave out. + * --skip-workspaces Add members only; do not attach workspaces. + * --apply Perform the writes. Omit for a dry run. + * --help Print this message. + * + * Safe to re-run: membership and workspace attachment are both idempotent, so a + * partially completed run can simply be executed again. + * + * Users who already belong to a *different* organization are reported and + * skipped — Sim allows one organization per user, and moving them would revoke + * their workspace permissions in the org they are leaving. Their workspaces are + * left alone too. Resolve those cases before re-running if they must be merged. + */ + +import { db } from '@sim/db' +import { member, organization, session, user, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { normalizeEmail } from '@sim/utils/string' +import { and, count, eq, inArray, isNull, ne } from 'drizzle-orm' +import { + createOrganizationWithOwner, + OrganizationSlugInvalidError, + OrganizationSlugTakenError, +} from '@/lib/billing/organizations/create-organization' +import { ensureUserInOrganization } from '@/lib/billing/organizations/membership' +import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/env-flags' +import { getInstanceOrganizationConfig } from '@/lib/organizations/instance-org' +import { attachOwnedWorkspacesToOrganization } from '@/lib/workspaces/organization-workspaces' +import { WORKSPACE_MODE } from '@/lib/workspaces/policy' + +const logger = createLogger('ConsolidateUsersIntoOrganization') + +/** Keeps `IN (...)` lists well under Postgres's 65535 bound-parameter ceiling. */ +const QUERY_CHUNK_SIZE = 1000 + +interface Options { + orgId?: string + orgSlug?: string + orgName?: string + ownerEmail?: string + excludeEmails: Set + skipWorkspaces: boolean + apply: boolean +} + +interface TargetOrganization { + id: string | null + name: string + slug: string + ownerUserId: string + ownerEmail: string + mustBeCreated: boolean +} + +interface UserRow { + id: string + email: string + name: string +} + +const USAGE = ` +Consolidate every user into a single organization. + + --org-id Consolidate into this existing organization + --org-slug Consolidate into the organization with this slug + --org-name Organization name; created if no match exists + --owner-email Owner of the organization (required when creating) + --exclude-emails Comma-separated emails to leave out + --skip-workspaces Add members only; do not attach workspaces + --apply Perform the writes (default is a dry run) + --help Print this message + +Example: + DATABASE_URL=... bun run apps/sim/scripts/consolidate-users-into-organization.ts \\ + --org-name "Acme Inc" --owner-email admin@acme.com --apply +` + +function parseArgs(argv: string[]): Options { + const options: Options = { + excludeEmails: new Set(), + skipWorkspaces: false, + apply: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const readValue = (): string => { + const value = argv[index + 1] + if (value === undefined || value.startsWith('--')) { + throw new Error(`${arg} requires a value`) + } + index += 1 + return value + } + + switch (arg) { + case '--org-id': + options.orgId = readValue() + break + case '--org-slug': + options.orgSlug = readValue() + break + case '--org-name': + options.orgName = readValue() + break + case '--owner-email': + options.ownerEmail = readValue() + break + case '--exclude-emails': + for (const email of readValue().split(',')) { + const trimmed = email.trim() + if (trimmed) options.excludeEmails.add(normalizeEmail(trimmed)) + } + break + case '--skip-workspaces': + options.skipWorkspaces = true + break + case '--apply': + options.apply = true + break + case '--help': + case '-h': + console.log(USAGE) + process.exit(0) + break + default: + throw new Error(`Unknown argument: ${arg}`) + } + } + + /** + * On a deployment already running instance-organization mode, the target is + * unambiguous — consolidating anywhere else would split the deployment across + * two organizations. Defaulting to it also makes the backfill a single + * argument-free command. + */ + if (!options.orgId && !options.orgSlug && !options.orgName) { + const instanceConfig = getInstanceOrganizationConfig() + if (instanceConfig) { + options.orgName = instanceConfig.name + options.orgSlug = instanceConfig.slug + options.ownerEmail = options.ownerEmail ?? instanceConfig.ownerEmail ?? undefined + } + } + + if (!options.orgId && !options.orgSlug && !options.orgName) { + throw new Error( + 'One of --org-id, --org-slug, or --org-name is required (or set INSTANCE_ORG_NAME)' + ) + } + + return options +} + +/** Mirrors the slug derivation used by `POST /api/v1/admin/organizations`. */ +function slugifyOrganizationName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + +async function findUserByEmail(email: string): Promise { + const [row] = await db + .select({ id: user.id, email: user.email, name: user.name }) + .from(user) + .where(eq(user.email, email)) + .limit(1) + return row ?? null +} + +async function findOrganizationOwner( + organizationId: string +): Promise<{ userId: string; email: string } | null> { + const [row] = await db + .select({ userId: member.userId, email: user.email }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) + .limit(1) + return row ?? null +} + +/** + * Resolves the organization to consolidate into, creating nothing. When the org + * does not exist yet the returned `id` is `null` and `mustBeCreated` is true, so + * a dry run can describe the plan without writing. + * + * An existing organization must already have an `owner` member row: workspace + * attachment routes the billed account to that owner and throws without one. + */ +async function resolveTargetOrganization(options: Options): Promise { + const existingWhere = options.orgId + ? eq(organization.id, options.orgId) + : options.orgSlug + ? eq(organization.slug, options.orgSlug) + : eq(organization.slug, slugifyOrganizationName(options.orgName as string)) + + const [existing] = await db + .select({ id: organization.id, name: organization.name, slug: organization.slug }) + .from(organization) + .where(existingWhere) + .limit(1) + + if (existing) { + const owner = await findOrganizationOwner(existing.id) + if (!owner) { + throw new Error( + `Organization ${existing.id} has no member row with role "owner". Workspace attachment ` + + 'routes the billed account to the org owner and cannot run without one.' + ) + } + return { + id: existing.id, + name: existing.name, + slug: existing.slug, + ownerUserId: owner.userId, + ownerEmail: owner.email, + mustBeCreated: false, + } + } + + if (options.orgId) { + throw new Error(`No organization found with id ${options.orgId}`) + } + if (!options.orgName) { + throw new Error( + `No organization found with slug ${options.orgSlug}. Pass --org-name to create it.` + ) + } + if (!options.ownerEmail) { + throw new Error('--owner-email is required when the organization must be created') + } + + const owner = await findUserByEmail(options.ownerEmail) + if (!owner) { + throw new Error(`No user found with email ${options.ownerEmail}`) + } + + const ownerMembership = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, owner.id)) + .limit(1) + if (ownerMembership.length > 0) { + throw new Error( + `Owner ${options.ownerEmail} already belongs to organization ${ownerMembership[0].organizationId}. ` + + 'Pass --org-id to consolidate into that organization instead of creating a new one.' + ) + } + + return { + id: null, + name: options.orgName, + slug: options.orgSlug?.trim() || slugifyOrganizationName(options.orgName), + ownerUserId: owner.id, + ownerEmail: owner.email, + mustBeCreated: true, + } +} + +/** Workspaces the attach helper would pick up, counted per owning user. */ +async function countAttachableWorkspacesByOwner(): Promise> { + const rows = await db + .select({ ownerId: workspace.ownerId, total: count() }) + .from(workspace) + .where( + and( + isNull(workspace.organizationId), + ne(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION), + isNull(workspace.archivedAt) + ) + ) + .groupBy(workspace.ownerId) + + return new Map(rows.map((row) => [row.ownerId, row.total])) +} + +async function countSessionsMissingActiveOrganization(userIds: string[]): Promise { + let total = 0 + for (let index = 0; index < userIds.length; index += QUERY_CHUNK_SIZE) { + const chunk = userIds.slice(index, index + QUERY_CHUNK_SIZE) + const [row] = await db + .select({ total: count() }) + .from(session) + .where(and(isNull(session.activeOrganizationId), inArray(session.userId, chunk))) + total += row?.total ?? 0 + } + return total +} + +/** + * Points live sessions at the organization. Better Auth stamps + * `activeOrganizationId` from the user's member row at session creation, so + * sessions that predate this run would otherwise carry `null` until the user + * signs in again. + */ +async function backfillActiveOrganization( + userIds: string[], + organizationId: string +): Promise { + let updated = 0 + for (let index = 0; index < userIds.length; index += QUERY_CHUNK_SIZE) { + const chunk = userIds.slice(index, index + QUERY_CHUNK_SIZE) + const rows = await db + .update(session) + .set({ activeOrganizationId: organizationId }) + .where(and(isNull(session.activeOrganizationId), inArray(session.userId, chunk))) + .returning({ id: session.id }) + updated += rows.length + } + return updated +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)) + + console.log('\nConsolidate users into a single organization') + console.log('===========================================\n') + + if (!isOrganizationsEnabled) { + console.log( + 'WARNING: organizations are not enabled for this process. Set ORGANIZATIONS_ENABLED=true and\n' + + ' NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true on the app, or the org UI stays hidden even\n' + + ' though the data written here is correct.\n' + ) + } + + const target = await resolveTargetOrganization(options) + + const allUsers = await db + .select({ id: user.id, email: user.email, name: user.name }) + .from(user) + .orderBy(user.email) + + const excluded = allUsers.filter((row) => options.excludeEmails.has(normalizeEmail(row.email))) + const candidates = allUsers.filter((row) => !options.excludeEmails.has(normalizeEmail(row.email))) + + const memberships = await db + .select({ userId: member.userId, organizationId: member.organizationId }) + .from(member) + const membershipByUser = new Map(memberships.map((row) => [row.userId, row.organizationId])) + + const alreadyInTarget: UserRow[] = [] + const inOtherOrganization: Array = [] + const toAdd: UserRow[] = [] + + for (const candidate of candidates) { + const currentOrganizationId = membershipByUser.get(candidate.id) + if (currentOrganizationId === undefined) { + toAdd.push(candidate) + } else if (target.id !== null && currentOrganizationId === target.id) { + alreadyInTarget.push(candidate) + } else { + inOtherOrganization.push({ ...candidate, organizationId: currentOrganizationId }) + } + } + + const attachableByOwner = options.skipWorkspaces + ? new Map() + : await countAttachableWorkspacesByOwner() + const consolidatedUsers = [...alreadyInTarget, ...toAdd] + const consolidatedUserIds = new Set(consolidatedUsers.map((row) => row.id)) + const ownersToAttach = [...attachableByOwner.entries()].filter(([ownerId]) => + consolidatedUserIds.has(ownerId) + ) + const workspacesToAttach = ownersToAttach.reduce((sum, [, total]) => sum + total, 0) + const sessionsToBackfill = await countSessionsMissingActiveOrganization( + consolidatedUsers.map((row) => row.id) + ) + + console.log('Target organization') + console.log(` name ${target.name}`) + console.log(` slug ${target.slug}`) + console.log(` id ${target.id ?? '(will be created)'}`) + console.log(` owner ${target.ownerEmail}`) + console.log('') + console.log('Users') + console.log(` total ${allUsers.length}`) + console.log(` already in target org ${alreadyInTarget.length}`) + console.log(` to add as members ${toAdd.length}`) + console.log(` in a different org ${inOtherOrganization.length} (skipped)`) + console.log(` excluded by flag ${excluded.length}`) + console.log('') + console.log('Workspaces') + console.log( + ` to attach ${workspacesToAttach}` + + (options.skipWorkspaces ? ' (--skip-workspaces)' : ` across ${ownersToAttach.length} owners`) + ) + console.log('') + console.log('Sessions') + console.log(` activeOrganizationId to backfill ${sessionsToBackfill}`) + console.log('') + + if (inOtherOrganization.length > 0) { + console.log('Users already in a different organization (not modified):') + for (const row of inOtherOrganization) { + console.log(` ${row.email} -> ${row.organizationId}`) + } + console.log( + '\n Sim allows one organization per user. Moving them would revoke their permissions in\n' + + ' the org they leave, so this script does not touch them. Remove them from that org in\n' + + ' the UI (or delete the org) and re-run.\n' + ) + } + + if (!options.apply) { + console.log('Dry run — nothing was written. Re-run with --apply to execute.\n') + return + } + + let organizationId = target.id + if (organizationId === null) { + const created = await createOrganizationWithOwner({ + ownerUserId: target.ownerUserId, + name: target.name, + slug: target.slug, + }) + organizationId = created.organizationId + console.log(`Created organization ${organizationId}`) + } + + const memberFailures: Array<{ email: string; reason: string }> = [] + let membersAdded = 0 + for (const candidate of toAdd) { + if (candidate.id === target.ownerUserId) continue + try { + const result = await ensureUserInOrganization({ + userId: candidate.id, + organizationId, + role: 'member', + skipBillingLogic: !isBillingEnabled, + skipSeatValidation: !isBillingEnabled, + }) + if (!result.success) { + memberFailures.push({ email: candidate.email, reason: result.error ?? 'unknown error' }) + } else if (!result.alreadyMember) { + membersAdded += 1 + } + } catch (error) { + memberFailures.push({ email: candidate.email, reason: getErrorMessage(error) }) + } + } + console.log(`Added ${membersAdded} members`) + + let workspacesAttached = 0 + const workspaceFailures: Array<{ email: string; reason: string }> = [] + if (!options.skipWorkspaces) { + const failedMemberIds = new Set( + memberFailures.map( + (failure) => candidates.find((row) => row.email === failure.email)?.id ?? '' + ) + ) + const emailByUserId = new Map(candidates.map((row) => [row.id, row.email])) + + for (const [ownerId] of ownersToAttach) { + if (failedMemberIds.has(ownerId)) continue + const email = emailByUserId.get(ownerId) ?? ownerId + try { + const result = await attachOwnedWorkspacesToOrganization({ + ownerUserId: ownerId, + organizationId, + externalMemberPolicy: 'keep-external', + }) + workspacesAttached += result.attachedWorkspaceIds.length + } catch (error) { + workspaceFailures.push({ email, reason: getErrorMessage(error) }) + } + } + console.log(`Attached ${workspacesAttached} workspaces`) + } + + const finalMemberIds = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + const sessionsUpdated = await backfillActiveOrganization( + finalMemberIds.map((row) => row.userId), + organizationId + ) + console.log(`Backfilled activeOrganizationId on ${sessionsUpdated} sessions`) + + console.log('') + console.log('Summary') + console.log(` organization ${organizationId}`) + console.log(` members total ${finalMemberIds.length}`) + console.log(` members added ${membersAdded}`) + console.log(` workspaces moved ${workspacesAttached}`) + console.log(` skipped users ${inOtherOrganization.length}`) + console.log('') + + if (memberFailures.length > 0 || workspaceFailures.length > 0) { + console.log('Failures:') + for (const failure of memberFailures) { + console.log(` member ${failure.email}: ${failure.reason}`) + } + for (const failure of workspaceFailures) { + console.log(` workspace ${failure.email}: ${failure.reason}`) + } + console.log('\nRe-running is safe — completed work is skipped on the next pass.\n') + process.exitCode = 1 + return + } + + console.log('Done. Restart the app if ORGANIZATIONS_ENABLED was changed in the same deploy.\n') +} + +main() + .then(() => process.exit(process.exitCode ?? 0)) + .catch((error) => { + if (error instanceof OrganizationSlugInvalidError) { + logger.error('Organization slug may only contain lowercase letters, numbers, "-", and "_"') + } else if (error instanceof OrganizationSlugTakenError) { + logger.error('That organization slug is already taken — pass --org-slug with a free value') + } else { + logger.error('Consolidation failed', { error: getErrorMessage(error) }) + } + process.exit(1) + }) diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 5c62ee6def7..5fb76fd9319 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -199,7 +199,18 @@ app: # Admin API Configuration ADMIN_API_KEY: "" # Admin API key for organization/user management (generate with: openssl rand -hex 32) - # Organizations & Permission Groups (defaults to "false" via envDefaults — set "true" here to enable) + # Enterprise feature set (defaults to "false" via envDefaults — set "true" here to enable) + # ENTERPRISE_ENABLED turns on every enterprise feature; the per-feature flags + # below override it either way. See docs: /platform/enterprise/self-hosted + ENTERPRISE_ENABLED: "" # Enable all enterprise features ("true" to enable) + NEXT_PUBLIC_ENTERPRISE_ENABLED: "" # Show all enterprise UI ("true" to enable) + + # Instance-tier organization — every user auto-joins this one org at signup + INSTANCE_ORG_NAME: "" # Organization name; setting it turns instance-org mode on + INSTANCE_ORG_SLUG: "" # Optional slug (derived from the name when empty) + INSTANCE_ORG_OWNER_EMAIL: "" # Optional owner email (defaults to the first user to sign up) + + # Organizations & Permission Groups (per-feature overrides) ACCESS_CONTROL_ENABLED: "" # Enable permission groups feature ("true" to enable) ORGANIZATIONS_ENABLED: "" # Enable organizations feature ("true" to enable) NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: "" # Show permission groups UI ("true" to enable) @@ -326,11 +337,15 @@ app: NEXT_PUBLIC_BRAND_NAME: "Sim" NEXT_PUBLIC_SUPPORT_EMAIL: "help@sim.ai" - # Feature flags (default off — set "true" in app.env or app.envDefaults to enable) - ACCESS_CONTROL_ENABLED: "false" - ORGANIZATIONS_ENABLED: "false" - NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: "false" - NEXT_PUBLIC_ORGANIZATIONS_ENABLED: "false" + # Feature flags — left EMPTY, not "false", on purpose. + # A per-feature flag set to "false" is an explicit override that wins over + # ENTERPRISE_ENABLED, so hardcoding "false" here would silently prevent the + # master switch from ever enabling these. Empty means "unset", which lets + # ENTERPRISE_ENABLED decide. Set "true"/"false" in app.env to override. + ACCESS_CONTROL_ENABLED: "" + ORGANIZATIONS_ENABLED: "" + NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: "" + NEXT_PUBLIC_ORGANIZATIONS_ENABLED: "" # Admission Gate ADMISSION_GATE_MAX_INFLIGHT: "500" # Max concurrent in-flight execution requests per pod diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index a3a7e5b3775..43b409bcc3d 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -131,6 +131,7 @@ export const AuditAction = { // Organizations ORGANIZATION_CREATED: 'organization.created', ORGANIZATION_UPDATED: 'organization.updated', + ORGANIZATION_DELETED: 'organization.deleted', ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked', ORGANIZATION_DOMAIN_ADDED: 'organization.domain.added', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 91f2fb0955b..7c708b92e7d 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -105,6 +105,7 @@ export const auditMock = { PASSWORD_RESET_REQUESTED: 'password.reset_requested', ORGANIZATION_CREATED: 'organization.created', ORGANIZATION_UPDATED: 'organization.updated', + ORGANIZATION_DELETED: 'organization.deleted', ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked', ORGANIZATION_DOMAIN_ADDED: 'organization.domain.added', diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 69b721e7b8c..5d81d5a65f7 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -23,6 +23,7 @@ export interface EnvFlagsMockState { isAppConfigEnabled: boolean isSlackExtendedScopesEnabled: boolean isTriggerDevEnabled: boolean + isEnterpriseEnabled: boolean isSsoEnabled: boolean isAccessControlEnabled: boolean isOrganizationsEnabled: boolean @@ -31,6 +32,7 @@ export interface EnvFlagsMockState { isAuditLogsEnabled: boolean isDataRetentionEnabled: boolean isDataDrainsEnabled: boolean + isSessionPoliciesEnabled: boolean isForkingEnabled: boolean isRemoteSandboxEnabled: boolean isDocSandboxEnabled: boolean @@ -64,11 +66,16 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isAppConfigEnabled: false, isSlackExtendedScopesEnabled: false, isTriggerDevEnabled: false, + isEnterpriseEnabled: false, isSsoEnabled: false, isAccessControlEnabled: false, isOrganizationsEnabled: false, - isInboxEnabled: false, - isWhitelabelingEnabled: false, + // True with billing off and no flags set — these carry a legacy default of + // `true` so upgrades do not remove a feature. See + // ENTERPRISE_FEATURE_LEGACY_DEFAULTS. + isInboxEnabled: true, + isWhitelabelingEnabled: true, + isSessionPoliciesEnabled: true, isAuditLogsEnabled: false, isDataRetentionEnabled: false, isDataDrainsEnabled: false, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0751de73108..8ac94ab18b1 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 979, - zodRoutes: 979, + totalRoutes: 982, + zodRoutes: 982, nonZodRoutes: 0, } as const diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index ab4c27fafe8..457c830675b 100644 --- a/scripts/setup/steps.ts +++ b/scripts/setup/steps.ts @@ -385,6 +385,13 @@ export async function promptUnlocks(vars: Map): Promise = [ { server: 'BILLING_ENABLED', client: 'NEXT_PUBLIC_BILLING_ENABLED' }, + { server: 'ENTERPRISE_ENABLED', client: 'NEXT_PUBLIC_ENTERPRISE_ENABLED' }, { server: 'ACCESS_CONTROL_ENABLED', client: 'NEXT_PUBLIC_ACCESS_CONTROL_ENABLED' }, { server: 'ORGANIZATIONS_ENABLED', client: 'NEXT_PUBLIC_ORGANIZATIONS_ENABLED' }, { server: 'WHITELABELING_ENABLED', client: 'NEXT_PUBLIC_WHITELABELING_ENABLED' }, { server: 'AUDIT_LOGS_ENABLED', client: 'NEXT_PUBLIC_AUDIT_LOGS_ENABLED' }, { server: 'DATA_RETENTION_ENABLED', client: 'NEXT_PUBLIC_DATA_RETENTION_ENABLED' }, + { server: 'SESSION_POLICIES_ENABLED', client: 'NEXT_PUBLIC_SESSION_POLICIES_ENABLED' }, { server: 'DATA_DRAINS_ENABLED', client: 'NEXT_PUBLIC_DATA_DRAINS_ENABLED' }, { server: 'FORKING_ENABLED', client: 'NEXT_PUBLIC_FORKING_ENABLED' }, { server: 'INBOX_ENABLED', client: 'NEXT_PUBLIC_INBOX_ENABLED' }, @@ -24,6 +26,11 @@ export const FLAG_TWINS: ReadonlyArray<{ server: string; client: string }> = [ /** Self-host feature unlocks offered by the wizard's Custom flow. */ export const SELF_HOST_UNLOCKS: ReadonlyArray<{ server: string; label: string; hint: string }> = [ + { + server: 'ENTERPRISE_ENABLED', + label: 'All enterprise features', + hint: 'enables everything below', + }, { server: 'ACCESS_CONTROL_ENABLED', label: 'Access control', @@ -31,7 +38,8 @@ export const SELF_HOST_UNLOCKS: ReadonlyArray<{ server: string; label: string; h }, { server: 'ORGANIZATIONS_ENABLED', label: 'Organizations', hint: 'multi-workspace orgs' }, { server: 'AUDIT_LOGS_ENABLED', label: 'Audit logs', hint: '' }, - { server: 'DATA_RETENTION_ENABLED', label: 'Data retention', hint: 'retention policies' }, + { server: 'DATA_RETENTION_ENABLED', label: 'Data retention', hint: 'deletes expired data' }, + { server: 'SESSION_POLICIES_ENABLED', label: 'Session policies', hint: 'session lifetime caps' }, { server: 'DATA_DRAINS_ENABLED', label: 'Data drains', hint: 'export streams' }, { server: 'FORKING_ENABLED', label: 'Workflow forking', hint: '' }, { server: 'INBOX_ENABLED', label: 'Inbox', hint: '' }, From 3abf1723dc106a45fe62dad3a9ce6a12e7a8b7ff Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 16:34:52 -0700 Subject: [PATCH 2/9] chore(helm): bump chart to 1.3.0 for the enterprise self-host values values.yaml gained the ENTERPRISE_ENABLED switch and INSTANCE_ORG_* keys, and the feature-flag envDefaults moved from "false" to empty so the master switch can resolve them. Additive and backward compatible, so a minor bump. --- helm/sim/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index 9550ef6d9c1..14df76348b2 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.2.0 +version: 1.3.0 appVersion: "v0.7.44" kubeVersion: ">=1.25.0-0" home: https://sim.ai From a0ca8cb7a2b68bf281aab89fecd92525fa6dac67 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 16:49:26 -0700 Subject: [PATCH 3/9] fix(self-host): address review findings on instance org and org delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the per-process instance-org id cache. It went stale once the organization was deleted through the Admin API, and clearing it from the delete handler would only heal the replica that served that request. The lookup runs on the signup path against a single-row table, so re-reading costs nothing and keeps every replica self-correcting. Scope the org-delete subscription conflict to entitled statuses. Matching any row regardless of status let a canceled subscription — which bills nobody — permanently block deletion. --- .../v1/admin/organizations/[id]/route.test.ts | 17 ++++++++++++ .../api/v1/admin/organizations/[id]/route.ts | 11 +++++++- .../lib/organizations/instance-org.test.ts | 19 ++++++++++---- apps/sim/lib/organizations/instance-org.ts | 26 +++++++------------ 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts index 848b36193ae..ee63b3f20c9 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts @@ -95,6 +95,23 @@ describe('admin organization DELETE', () => { expect(mockDetachOrganizationWorkspaces).not.toHaveBeenCalled() }) + it('is not blocked by a canceled subscription', async () => { + queueOrganization() + /** + * The status filter runs in SQL, so a canceled row never comes back — an + * empty result here is what an organization whose subscription already + * ended looks like. Without that filter the row would block deletion + * forever even though it bills nobody. + */ + queueTableRows(schemaMock.subscription, []) + queueTableRows(schemaMock.member, [{ value: 1 }]) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + expect(response.status).toBe(200) + expect(mockDetachOrganizationWorkspaces).toHaveBeenCalledWith(ORG_ID) + }) + it('detaches workspaces before deleting the organization', async () => { queueOrganization() queueTableRows(schemaMock.subscription, []) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index b0ad98d7a5c..98c1cef9402 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -242,11 +242,20 @@ export const DELETE = withRouteHandler( * cascades. Deleting an organization out from under a live subscription * would strand it, and its Stripe billing, against an id that no longer * resolves. Refuse instead of guessing whether to cancel. + * + * Scoped to entitled statuses. A canceled or ended row bills nobody, so + * treating it as a blocker would make an organization that once had a + * subscription permanently undeletable. */ const [existingSubscription] = await db .select({ id: subscription.id, plan: subscription.plan }) .from(subscription) - .where(eq(subscription.referenceId, organizationId)) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) .limit(1) if (existingSubscription) { diff --git a/apps/sim/lib/organizations/instance-org.test.ts b/apps/sim/lib/organizations/instance-org.test.ts index c3cc3623907..ff8157fb653 100644 --- a/apps/sim/lib/organizations/instance-org.test.ts +++ b/apps/sim/lib/organizations/instance-org.test.ts @@ -56,7 +56,6 @@ import { getInstanceOrganizationConfig, isInstanceOrganizationMode, joinInstanceOrganization, - resetInstanceOrganizationCache, } from '@/lib/organizations/instance-org' const ORIGINAL_ENV = { ...process.env } @@ -72,7 +71,6 @@ describe('instance organization', () => { beforeEach(() => { vi.clearAllMocks() queuedRows.length = 0 - resetInstanceOrganizationCache() setEnvFlags({ isBillingEnabled: false }) mockSelect.mockImplementation(buildSelectChain) mockExecute.mockResolvedValue(undefined) @@ -176,14 +174,25 @@ describe('instance organization', () => { expect(mockCreateOrganizationWithOwnerTx).not.toHaveBeenCalled() }) - it('caches the id so repeat calls do not re-query', async () => { + it('re-reads the organization on every call instead of caching the id', async () => { + /** + * A per-process cache goes stale when the organization is deleted through + * the Admin API, and clearing it from the delete handler would only heal + * the replica that served that request. Re-reading keeps every replica + * self-correcting. + */ queueRows([{ id: 'org_existing' }]) await ensureInstanceOrganization('user-1') const callsAfterFirst = mockSelect.mock.calls.length - await ensureInstanceOrganization('user-2') + queueRows([]) + queueRows([]) + queueRows([]) + mockCreateOrganizationWithOwnerTx.mockResolvedValue({ organizationId: 'org_recreated' }) + const recreated = await ensureInstanceOrganization('user-2') - expect(mockSelect.mock.calls.length).toBe(callsAfterFirst) + expect(mockSelect.mock.calls.length).toBeGreaterThan(callsAfterFirst) + expect(recreated).toBe('org_recreated') }) }) diff --git a/apps/sim/lib/organizations/instance-org.ts b/apps/sim/lib/organizations/instance-org.ts index 5c8e31bbf90..09abdaccb3a 100644 --- a/apps/sim/lib/organizations/instance-org.ts +++ b/apps/sim/lib/organizations/instance-org.ts @@ -32,14 +32,6 @@ const logger = createLogger('InstanceOrganization') /** Bounds the wait for a concurrent provisioning attempt on another replica. */ const INSTANCE_ORG_LOCK_TIMEOUT_MS = 10_000 -/** - * Once provisioned, the instance organization's id never changes, so it is - * cached for the life of the process. `null` is deliberately not cached — a - * miss means provisioning has not happened yet, and the next caller should - * retry rather than be told "no org" forever. - */ -let cachedInstanceOrganizationId: string | null = null - /** Derives a slug the same way the admin organization API does. */ function slugifyOrganizationName(name: string): string { return name @@ -95,10 +87,17 @@ export function isInstanceOrganizationMode(): boolean { /** * Returns the instance organization's id without creating it, or `null` when * the mode is off or provisioning has not run yet. + * + * Deliberately uncached. Caching the id per process looks free — it never + * changes while the organization exists — but it goes stale the moment the + * organization is deleted (the Admin API allows this), and every later signup + * then tries to join an id that no longer resolves. Clearing the cache from the + * delete handler would only fix the replica that served the request, leaving + * every other replica broken until restart. The read is one lookup on a table + * that holds a single row in this mode, and it only runs on the signup path, so + * there is nothing worth caching against that failure mode. */ export async function getInstanceOrganizationId(): Promise { - if (cachedInstanceOrganizationId) return cachedInstanceOrganizationId - const config = getInstanceOrganizationConfig() if (!config) return null @@ -108,7 +107,6 @@ export async function getInstanceOrganizationId(): Promise { .where(eq(organization.slug, config.slug)) .limit(1) - if (row) cachedInstanceOrganizationId = row.id return row?.id ?? null } @@ -221,7 +219,6 @@ export async function ensureInstanceOrganization( return created.organizationId }) - if (organizationId) cachedInstanceOrganizationId = organizationId return organizationId } catch (error) { /** @@ -288,8 +285,3 @@ export async function joinInstanceOrganization(userId: string): Promise { }) } } - -/** Clears the cached id. Exported for tests. */ -export function resetInstanceOrganizationCache(): void { - cachedInstanceOrganizationId = null -} From c55e5f174f8f982cca1ee694e144af8e6964bbe9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 17:29:29 -0700 Subject: [PATCH 4/9] fix(admin): block org delete on any live subscription, not just entitled ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ENTITLED_SUBSCRIPTION_STATUSES excludes trialing, so a trial — which grants no entitlement but is a live Stripe subscription that will convert — slipped past the delete guard and could be stranded against a removed organization id. Adds TERMINAL_SUBSCRIPTION_STATUSES and inverts the predicate: block unless the row is finished. Expressed as the terminal set so a status Stripe adds later defaults to blocking, which is the safe direction for a destructive operation. --- .../api/v1/admin/organizations/[id]/route.ts | 21 ++++++++++----- .../lib/billing/subscriptions/utils.test.ts | 26 +++++++++++++++++++ apps/sim/lib/billing/subscriptions/utils.ts | 16 ++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 98c1cef9402..80bd3a67a86 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -34,7 +34,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, count, eq, inArray } from 'drizzle-orm' +import { and, count, eq, inArray, isNull, not, or } from 'drizzle-orm' import { adminV1DeleteOrganizationContract, adminV1GetOrganizationContract, @@ -47,7 +47,10 @@ import { OrganizationSlugTakenError, validateOrganizationSlugOrThrow, } from '@/lib/billing/organizations/create-organization' -import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { + ENTITLED_SUBSCRIPTION_STATUSES, + TERMINAL_SUBSCRIPTION_STATUSES, +} from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -243,9 +246,12 @@ export const DELETE = withRouteHandler( * would strand it, and its Stripe billing, against an id that no longer * resolves. Refuse instead of guessing whether to cancel. * - * Scoped to entitled statuses. A canceled or ended row bills nobody, so - * treating it as a blocker would make an organization that once had a - * subscription permanently undeletable. + * Scoped to non-terminal rows. A canceled row bills nobody, so treating + * it as a blocker would make an organization that once had a + * subscription permanently undeletable — but entitlement is the wrong + * test in the other direction too, since a `trialing` subscription grants + * nothing today and is still live in Stripe. A null status is unknown, so + * it blocks. */ const [existingSubscription] = await db .select({ id: subscription.id, plan: subscription.plan }) @@ -253,7 +259,10 @@ export const DELETE = withRouteHandler( .where( and( eq(subscription.referenceId, organizationId), - inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + or( + isNull(subscription.status), + not(inArray(subscription.status, TERMINAL_SUBSCRIPTION_STATUSES)) + ) ) ) .limit(1) diff --git a/apps/sim/lib/billing/subscriptions/utils.test.ts b/apps/sim/lib/billing/subscriptions/utils.test.ts index 757a8a2031f..155794a1a23 100644 --- a/apps/sim/lib/billing/subscriptions/utils.test.ts +++ b/apps/sim/lib/billing/subscriptions/utils.test.ts @@ -11,6 +11,7 @@ import { hasPaidSubscriptionStatus, hasUsableSubscriptionAccess, hasUsableSubscriptionStatus, + TERMINAL_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' describe('billing subscription status helpers', () => { @@ -50,3 +51,28 @@ describe('billing subscription status helpers', () => { expect(getEffectiveSeats({ plan: 'team_8000', status: 'canceled', seats: null })).toBe(0) }) }) + +describe('TERMINAL_SUBSCRIPTION_STATUSES', () => { + const terminal = TERMINAL_SUBSCRIPTION_STATUSES as readonly string[] + + it('covers only the statuses that can no longer bill', () => { + expect(terminal).toEqual(['canceled', 'incomplete_expired']) + }) + + it('does not treat trialing as terminal', () => { + /** + * A trial grants no entitlement, so it is absent from + * ENTITLED_SUBSCRIPTION_STATUSES — but it is a live Stripe subscription + * that will convert. Anything keying off "is this row still real" must not + * reuse the entitlement set, or it will happily delete out from under an + * active trial. + */ + expect(terminal).not.toContain('trialing') + }) + + it('treats every other live status as non-terminal', () => { + for (const status of ['active', 'past_due', 'unpaid', 'trialing', 'incomplete']) { + expect(terminal).not.toContain(status) + } + }) +}) diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index e4d226f96c2..3d7fc1f3a09 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -20,6 +20,22 @@ export const ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'past_due'] as const export const USABLE_SUBSCRIPTION_STATUSES = ['active'] as const +/** + * Statuses where the subscription is finished and can no longer bill anyone. + * + * The inverse of this set — `active`, `past_due`, `unpaid`, `trialing`, + * `incomplete` — is still attached to a live Stripe subscription that can + * convert or retry, even where it grants no entitlement today. Use this, not + * {@link ENTITLED_SUBSCRIPTION_STATUSES}, when the question is "would removing + * the thing this row points at strand live billing?" — a `trialing` + * subscription is unentitled but very much alive. + * + * Deliberately expressed as the terminal set rather than the live one, so a + * status Stripe adds later is treated as live by default. For a destructive + * operation that is the safe direction to be wrong in. + */ +export const TERMINAL_SUBSCRIPTION_STATUSES = ['canceled', 'incomplete_expired'] as const + /** * Returns true when a subscription should still count as a paid plan entitlement. */ From d12c8710799dea5d4a81b74f863c4547601edaab Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 17:49:31 -0700 Subject: [PATCH 5/9] fix(self-host): resolve SSO and access-control in the UI, not the raw env var Nine client consumers still read NEXT_PUBLIC_SSO_ENABLED / NEXT_PUBLIC_ACCESS_CONTROL_ENABLED directly while the server gates and settings nav had moved to the resolver. With only ENTERPRISE_ENABLED set that produced dead ends: the SSO settings section appeared but ssoClient() was never registered and no login button rendered, and the Access Control section appeared but its page reported "not entitled". Points every consumer at isSsoEnabled / isAccessControlEnabled so visibility and capability come from one place. --- apps/sim/app/(auth)/components/sso-login-button.tsx | 4 ++-- apps/sim/app/(auth)/login/login-form.tsx | 5 +++-- apps/sim/app/(auth)/signup/signup-form.tsx | 5 +++-- apps/sim/app/(auth)/sso/page.tsx | 4 ++-- .../(landing)/components/auth-modal/auth-modal.tsx | 5 +++-- .../files/components/share-modal/share-modal.tsx | 4 ++-- .../components/deploy-modal/components/chat/chat.tsx | 6 ++---- .../ee/access-control/components/access-control.tsx | 11 ++++++++--- apps/sim/lib/auth/auth-client.ts | 5 ++--- 9 files changed, 27 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index ddca42526ca..b675cd5e01c 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -1,7 +1,7 @@ 'use client' import { Chip, cn } from '@sim/emcn' import { useRouter } from 'next/navigation' -import { getEnv, isTruthy } from '@/lib/core/config/env' +import { isSsoEnabled } from '@/lib/core/config/env-flags' import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' interface SSOLoginButtonProps { @@ -17,7 +17,7 @@ export function SSOLoginButton({ }: SSOLoginButtonProps) { const router = useRouter() - if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) { + if (!isSsoEnabled) { return null } diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 359dd2f2671..a2349c428eb 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -16,7 +16,8 @@ import { useRouter, useSearchParams } from 'next/navigation' import { requestJson } from '@/lib/api/client/request' import { forgetPasswordContract } from '@/lib/api/contracts' import { client } from '@/lib/auth/auth-client' -import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' +import { getEnv, isFalsy } from '@/lib/core/config/env' +import { isSsoEnabled } from '@/lib/core/config/env-flags' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -343,7 +344,7 @@ export default function LoginPage({ } } - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) + const ssoEnabled = isSsoEnabled const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) const hasSocial = githubAvailable || googleAvailable || microsoftAvailable const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index f55bdfac5c9..dfd0428f30b 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -6,7 +6,8 @@ import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { client, useSession } from '@/lib/auth/auth-client' -import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' +import { getEnv, isFalsy } from '@/lib/core/config/env' +import { isSsoEnabled } from '@/lib/core/config/env-flags' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent, captureEvent } from '@/lib/posthog/client' @@ -360,7 +361,7 @@ function SignupFormContent({ } } - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) + const ssoEnabled = isSsoEnabled const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && emailSignupEnabled const hasSocial = githubAvailable || googleAvailable || microsoftAvailable diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx index db865bec590..b1e27f22608 100644 --- a/apps/sim/app/(auth)/sso/page.tsx +++ b/apps/sim/app/(auth)/sso/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { redirect } from 'next/navigation' -import { getEnv, isTruthy } from '@/lib/core/config/env' +import { isSsoEnabled } from '@/lib/core/config/env-flags' import SSOForm from '@/ee/sso/components/sso-form' export const metadata: Metadata = { @@ -11,7 +11,7 @@ export const metadata: Metadata = { export const dynamic = 'force-dynamic' export default async function SSOPage() { - if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) { + if (!isSsoEnabled) { redirect('/login') } diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index 5252032a921..b0b7aa127d3 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -18,7 +18,8 @@ import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth' import { client } from '@/lib/auth/auth-client' -import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' +import { getEnv, isFalsy } from '@/lib/core/config/env' +import { isSsoEnabled } from '@/lib/core/config/env-flags' import { captureClientEvent } from '@/lib/posthog/client' import type { PostHogEventMap } from '@/lib/posthog/events' import { getBrandConfig } from '@/ee/whitelabeling' @@ -75,7 +76,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal fetchProviderStatus().then(setProviderStatus) }, []) - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) + const ssoEnabled = isSsoEnabled const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) /** diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx index d4e1105e087..b8d3f9de074 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx @@ -16,7 +16,7 @@ import { Send } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' import { GeneratedPasswordInput } from '@/components/ui' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' -import { getEnv, isTruthy } from '@/lib/core/config/env' +import { isSsoEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares' @@ -91,7 +91,7 @@ export function ShareModal({ const isAuthTypeAllowed = (mode: ShareAuthType) => allowedAuthTypes === null || allowedAuthTypes.includes(mode) - const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) || savedAccessMode === 'sso' + const ssoEnabled = isSsoEnabled || savedAccessMode === 'sso' const candidateAuthTypes: ShareAuthType[] = [ 'public', 'password', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 12b820efe5f..f08467fe3e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -22,7 +22,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { normalizeEmail } from '@sim/utils/string' import { AlertTriangle, Check } from 'lucide-react' import { GeneratedPasswordInput } from '@/components/ui' -import { getEnv, isTruthy } from '@/lib/core/config/env' +import { isSsoEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select' @@ -713,9 +713,7 @@ function AuthSelector({ const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes const ssoAvailable = - isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) || - savedAuthType === 'sso' || - (allowedAuthTypes?.includes('sso') ?? false) + isSsoEnabled || savedAuthType === 'sso' || (allowedAuthTypes?.includes('sso') ?? false) const baseAuthOptions: AuthType[] = ssoAvailable ? ['public', 'password', 'email', 'sso'] : ['public', 'password', 'email'] diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 6d2cb78d2e3..ce4330347b1 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -18,7 +18,7 @@ import { ArrowRight, Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' -import { getEnv, isTruthy } from '@/lib/core/config/env' +import { isAccessControlEnabled } from '@/lib/core/config/env-flags' import { groupIdParam, groupIdUrlKeys, @@ -73,9 +73,14 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon const { data: organizationWorkspaces = [], isPending: workspacesLoading } = useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin) - const accessControlEnabledLocally = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')) + /** + * Must be the resolved flag, not the raw `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` + * read. The settings nav decides visibility from the same resolver, so + * reading the bare var here let a deployment with only `ENTERPRISE_ENABLED` + * set show the section and then refuse to manage it. + */ const isEntitled = - accessControlEnabledLocally || + isAccessControlEnabled || !!userPermissionConfig?.entitled || isEnterprise(organizationBillingData?.data?.subscriptionPlan) const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index b1ca36b84d2..0f91980cb52 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -10,8 +10,7 @@ import { } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' import type { auth } from '@/lib/auth' -import { env } from '@/lib/core/config/env' -import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/env-flags' +import { isBillingEnabled, isOrganizationsEnabled, isSsoEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl, getBrowserOrigin } from '@/lib/core/utils/urls' import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider' @@ -34,7 +33,7 @@ export const client = createAuthClient({ ] : []), ...(isOrganizationsEnabled ? [organizationClient()] : []), - ...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []), + ...(isSsoEnabled ? [ssoClient()] : []), ], }) From 1b80ae256de64cdda2b478605e63e0d0b6e64d82 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 18:01:51 -0700 Subject: [PATCH 6/9] fix(admin): validate retention workspace targets on the Admin API too retentionOverrides and per-workspace PII rules both name a workspace, and neither field is a foreign key. The settings UI rejected ids belonging to another organization; the Admin API did not, so the two paths could persist different data for the same org. Extracts the check as getForeignWorkspaceTargetsReason and points both routes at it, so they cannot drift apart again. --- .../[id]/data-retention/route.ts | 37 +++++-------- .../[id]/data-retention/route.ts | 20 ++++++- apps/sim/lib/billing/retention.test.ts | 52 ++++++++++++++++++- apps/sim/lib/billing/retention.ts | 42 +++++++++++++++ 4 files changed, 125 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index 1811b6a355e..2b85f41795a 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -1,9 +1,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import type { DataRetentionSettings } from '@sim/db/schema' -import { member, organization, workspace } from '@sim/db/schema' +import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { type OrganizationRetentionValues, @@ -13,7 +13,10 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { getPiiRedactionDenialReason } from '@/lib/billing/retention' +import { + getForeignWorkspaceTargetsReason, + getPiiRedactionDenialReason, +} from '@/lib/billing/retention' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -220,27 +223,13 @@ export const PUT = withRouteHandler( merged.retentionOverrides = body.retentionOverrides } - const targetedWorkspaceIds = new Set() - for (const override of body.retentionOverrides ?? []) { - targetedWorkspaceIds.add(override.workspaceId) - } - for (const rule of body.piiRedaction?.rules ?? []) { - if (rule.workspaceId) targetedWorkspaceIds.add(rule.workspaceId) - } - if (targetedWorkspaceIds.size > 0) { - const ids = [...targetedWorkspaceIds] - const orgWorkspaces = await db - .select({ id: workspace.id }) - .from(workspace) - .where(and(eq(workspace.organizationId, organizationId), inArray(workspace.id, ids))) - const known = new Set(orgWorkspaces.map((row) => row.id)) - const unknown = ids.filter((id) => !known.has(id)) - if (unknown.length > 0) { - return NextResponse.json( - { error: `Override targets workspaces outside this organization: ${unknown.join(', ')}` }, - { status: 400 } - ) - } + const foreignTargetsReason = await getForeignWorkspaceTargetsReason({ + organizationId, + retentionOverrides: body.retentionOverrides, + piiRedaction: body.piiRedaction, + }) + if (foreignTargetsReason) { + return NextResponse.json({ error: foreignTargetsReason }, { status: 400 }) } const [updated] = await db diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts index f7bee24d40f..355e49f0d55 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts @@ -23,13 +23,17 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { adminV1UpdateOrganizationDataRetentionContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' -import { getPiiRedactionDenialReason } from '@/lib/billing/retention' +import { + getForeignWorkspaceTargetsReason, + getPiiRedactionDenialReason, +} from '@/lib/billing/retention' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, adminValidationErrorResponse, + badRequestResponse, forbiddenResponse, internalErrorResponse, notFoundResponse, @@ -105,6 +109,20 @@ export const PATCH = withRouteHandler( merged.retentionOverrides = body.retentionOverrides } + /** + * Same ownership check the settings UI applies. Neither `workspaceId` + * field is a foreign key, so without it the Admin API could persist an + * override naming another organization's workspace. + */ + const foreignTargetsReason = await getForeignWorkspaceTargetsReason({ + organizationId, + retentionOverrides: body.retentionOverrides, + piiRedaction: body.piiRedaction, + }) + if (foreignTargetsReason) { + return badRequestResponse(foreignTargetsReason) + } + await db .update(organization) .set({ dataRetentionSettings: merged, updatedAt: new Date() }) diff --git a/apps/sim/lib/billing/retention.test.ts b/apps/sim/lib/billing/retention.test.ts index 84fca461408..d59d0814d66 100644 --- a/apps/sim/lib/billing/retention.test.ts +++ b/apps/sim/lib/billing/retention.test.ts @@ -2,9 +2,11 @@ * @vitest-environment node */ import type { DataRetentionSettings, PiiRedactionRule } from '@sim/db/schema' -import { describe, expect, it } from 'vitest' +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it } from 'vitest' import { DEFAULT_PII_REDACTION, + getForeignWorkspaceTargetsReason, getPiiRedactionDenialReason, resolveEffectivePiiRedaction, resolveEffectiveRetentionHours, @@ -466,3 +468,51 @@ describe('getPiiRedactionDenialReason', () => { ).toBeNull() }) }) + +describe('getForeignWorkspaceTargetsReason', () => { + beforeEach(resetDbChainMock) + afterAll(resetDbChainMock) + + it('skips the lookup entirely when nothing targets a workspace', async () => { + await expect( + getForeignWorkspaceTargetsReason({ + organizationId: 'org-1', + retentionOverrides: [], + piiRedaction: { rules: [{ workspaceId: null }] }, + }) + ).resolves.toBeNull() + }) + + it('accepts overrides whose workspaces belong to the organization', async () => { + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }, { id: 'ws-2' }]) + + await expect( + getForeignWorkspaceTargetsReason({ + organizationId: 'org-1', + retentionOverrides: [{ workspaceId: 'ws-1' }, { workspaceId: 'ws-2' }], + }) + ).resolves.toBeNull() + }) + + it('rejects an override naming a workspace the organization does not own', async () => { + queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) + + await expect( + getForeignWorkspaceTargetsReason({ + organizationId: 'org-1', + retentionOverrides: [{ workspaceId: 'ws-1' }, { workspaceId: 'ws-foreign' }], + }) + ).resolves.toContain('ws-foreign') + }) + + it('also checks workspaces named by PII rules, not just overrides', async () => { + queueTableRows(schemaMock.workspace, []) + + await expect( + getForeignWorkspaceTargetsReason({ + organizationId: 'org-1', + piiRedaction: { rules: [{ workspaceId: 'ws-foreign' }] }, + }) + ).resolves.toContain('ws-foreign') + }) +}) diff --git a/apps/sim/lib/billing/retention.ts b/apps/sim/lib/billing/retention.ts index 878940ec12e..3a7fa002ea8 100644 --- a/apps/sim/lib/billing/retention.ts +++ b/apps/sim/lib/billing/retention.ts @@ -1,4 +1,7 @@ +import { db } from '@sim/db' import type { CustomPiiPattern, DataRetentionSettings, PiiStagePolicy } from '@sim/db/schema' +import { workspace } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' import { coercePiiLanguage, DEFAULT_PII_LANGUAGE, @@ -214,3 +217,42 @@ export function getPiiRedactionDenialReason(params: { ? 'Granular PII redaction (workflow input and block outputs) is not enabled for this organization' : null } + +/** + * Rejects retention settings that point at workspaces the organization does not + * own. Returns `null` when every referenced workspace belongs to it. + * + * Both `retentionOverrides` and per-workspace `piiRedaction` rules name a + * workspace, and neither is a foreign key — an id that belongs to another + * organization would persist silently and then be applied by + * `resolveEffectiveRetentionHours` / `resolveEffectivePiiRedaction` to whatever + * workspace later matched it. Shared so the settings API and the Admin API + * cannot accept different data for the same organization. + */ +export async function getForeignWorkspaceTargetsReason(params: { + organizationId: string + retentionOverrides?: Array<{ workspaceId: string }> | null + piiRedaction?: PiiRedactionRulesLike | null +}): Promise { + const targeted = new Set() + for (const override of params.retentionOverrides ?? []) { + if (override?.workspaceId) targeted.add(override.workspaceId) + } + for (const rule of params.piiRedaction?.rules ?? []) { + if (rule.workspaceId) targeted.add(rule.workspaceId) + } + if (targeted.size === 0) return null + + const ids = [...targeted] + const owned = await db + .select({ id: workspace.id }) + .from(workspace) + .where(and(eq(workspace.organizationId, params.organizationId), inArray(workspace.id, ids))) + + const known = new Set(owned.map((row) => row.id)) + const unknown = ids.filter((id) => !known.has(id)) + + return unknown.length > 0 + ? `Override targets workspaces outside this organization: ${unknown.join(', ')}` + : null +} From 81b1849156680589da3e193463040a9caf79e9a8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 18:22:48 -0700 Subject: [PATCH 7/9] fix(self-host): close three review findings on admin routes and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make org delete atomic. detachOrganizationWorkspaces committed on its own, so a failed delete left workspaces detached and re-billed while the organization, its members, and its settings survived. Adds a Tx variant so both commit together. Gate the admin session-policy PATCH on entitlement, matching the settings UI. Without it the stored policy was inert — getSessionPolicy resolves to no-op when the feature is off, so the one eager clamp would be undone on the next refresh. Stop emitting plan-wide housekeeping when billing is off. It is keyed to the hosted free-tier 30-day window, the same default the per-workspace pass deliberately refuses to apply off-hosted. --- .../v1/admin/organizations/[id]/route.test.ts | 21 +++++++++--------- .../api/v1/admin/organizations/[id]/route.ts | 14 ++++++++---- .../[id]/session-policy/route.ts | 18 +++++++++++++++ .../lib/billing/cleanup-dispatcher.test.ts | 6 +++++ apps/sim/lib/billing/cleanup-dispatcher.ts | 14 +++++++++++- .../lib/workspaces/organization-workspaces.ts | 22 ++++++++++++++++--- 6 files changed, 77 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts index ee63b3f20c9..f8ebca64ee0 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts @@ -4,16 +4,16 @@ import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDetachOrganizationWorkspaces, mockDelete, mockAuthenticateAdminRequest } = vi.hoisted( +const { mockDetachOrganizationWorkspacesTx, mockDelete, mockAuthenticateAdminRequest } = vi.hoisted( () => ({ - mockDetachOrganizationWorkspaces: vi.fn(), + mockDetachOrganizationWorkspacesTx: vi.fn(), mockDelete: vi.fn(), mockAuthenticateAdminRequest: vi.fn(), }) ) vi.mock('@/lib/workspaces/organization-workspaces', () => ({ - detachOrganizationWorkspaces: mockDetachOrganizationWorkspaces, + detachOrganizationWorkspacesTx: mockDetachOrganizationWorkspacesTx, })) vi.mock('@/app/api/v1/admin/auth', () => ({ @@ -54,7 +54,7 @@ describe('admin organization DELETE', () => { vi.clearAllMocks() resetDbChainMock() mockAuthenticateAdminRequest.mockReturnValue({ authenticated: true }) - mockDetachOrganizationWorkspaces.mockResolvedValue({ + mockDetachOrganizationWorkspacesTx.mockResolvedValue({ detachedWorkspaceIds: ['ws-1', 'ws-2'], billedAccountUserId: 'user-1', }) @@ -69,7 +69,7 @@ describe('admin organization DELETE', () => { const response = await DELETE(deleteRequest('acme-inc'), routeContext) expect(response.status).toBe(404) - expect(mockDetachOrganizationWorkspaces).not.toHaveBeenCalled() + expect(mockDetachOrganizationWorkspacesTx).not.toHaveBeenCalled() }) it('refuses when confirmSlug does not match the organization slug', async () => { @@ -78,7 +78,7 @@ describe('admin organization DELETE', () => { const response = await DELETE(deleteRequest('wrong-slug'), routeContext) expect(response.status).toBe(400) - expect(mockDetachOrganizationWorkspaces).not.toHaveBeenCalled() + expect(mockDetachOrganizationWorkspacesTx).not.toHaveBeenCalled() }) it('refuses with 409 while a subscription still references the organization', async () => { @@ -92,7 +92,7 @@ describe('admin organization DELETE', () => { * strand the row and its Stripe billing against a dangling id. */ expect(response.status).toBe(409) - expect(mockDetachOrganizationWorkspaces).not.toHaveBeenCalled() + expect(mockDetachOrganizationWorkspacesTx).not.toHaveBeenCalled() }) it('is not blocked by a canceled subscription', async () => { @@ -109,7 +109,7 @@ describe('admin organization DELETE', () => { const response = await DELETE(deleteRequest('acme-inc'), routeContext) expect(response.status).toBe(200) - expect(mockDetachOrganizationWorkspaces).toHaveBeenCalledWith(ORG_ID) + expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID) }) it('detaches workspaces before deleting the organization', async () => { @@ -123,9 +123,10 @@ describe('admin organization DELETE', () => { /** * The `ON DELETE SET NULL` foreign key alone would leave workspaces in * `organization` mode with a null org id and drop the organization's - * storage ledger without crediting the new payer. + * storage ledger without crediting the new payer. The transaction is passed + * through so the detach and the delete commit together. */ - expect(mockDetachOrganizationWorkspaces).toHaveBeenCalledWith(ORG_ID) + expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID) const body = await response.json() expect(body.data).toMatchObject({ diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 80bd3a67a86..4b56bd9fea4 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -52,7 +52,7 @@ import { TERMINAL_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces' +import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, @@ -287,10 +287,16 @@ export const DELETE = withRouteHandler( * ledger without returning those bytes to the workspace's new payer. * This helper does the payer transfer, resets the mode, and re-grants * admin permissions. + * + * Both run in one transaction: a detach that committed on its own would + * leave workspaces re-billed to their owners while the organization, + * its members, and its settings survived a failed delete. */ - const { detachedWorkspaceIds } = await detachOrganizationWorkspaces(organizationId) - - await db.delete(organization).where(eq(organization.id, organizationId)) + const { detachedWorkspaceIds } = await db.transaction(async (tx) => { + const detached = await detachOrganizationWorkspacesTx(tx, organizationId) + await tx.delete(organization).where(eq(organization.id, organizationId)) + return detached + }) logger.info(`Admin API: Deleted organization ${organizationId}`, { slug: existing.slug, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts index c666f3aaded..c38a26c08f4 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts @@ -21,11 +21,14 @@ import { adminV1UpdateOrganizationSessionPolicyContract } from '@/lib/api/contra import { parseRequest } from '@/lib/api/server' import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy' import { eagerClampOrgSessions, invalidateSessionPolicyCache } from '@/lib/auth/session-policy' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { isBillingEnabled, isSessionPoliciesEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, adminValidationErrorResponse, + forbiddenResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -64,6 +67,21 @@ export const PATCH = withRouteHandler( return notFoundResponse('Organization') } + /** + * Same entitlement gate the settings UI applies. Without it the stored + * policy is inert theater: `getSessionPolicy` resolves to no-op when the + * feature is off, so limits would sit in the database and the one eager + * clamp here would be undone by the next session refresh. + */ + const entitled = await isOrganizationFeatureEntitled(organizationId, isSessionPoliciesEnabled) + if (!entitled) { + return forbiddenResponse( + isBillingEnabled + ? 'Session policies are available on Enterprise plans only' + : 'Session policies are disabled. Set ENTERPRISE_ENABLED or SESSION_POLICIES_ENABLED to enable them.' + ) + } + const merged = { maxSessionHours: body.maxSessionHours, idleTimeoutHours: body.idleTimeoutHours, diff --git a/apps/sim/lib/billing/cleanup-dispatcher.test.ts b/apps/sim/lib/billing/cleanup-dispatcher.test.ts index abda30b3978..e1d3f8977ac 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.test.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.test.ts @@ -97,5 +97,11 @@ describe('dispatchCleanupJobs retention gate', () => { expect(result.workspaceCount).toBe(0) expect(mockGetOrganizationSubscription).not.toHaveBeenCalled() + /** + * No chunks at all, including the plan-wide housekeeping one. That chunk is + * keyed to the hosted free-tier 30-day window, so emitting it off-hosted + * would act on the very default the per-workspace pass refuses to apply. + */ + expect(result.chunkCount).toBe(0) }) }) diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index b02baf872e2..8b8608d63a4 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -290,7 +290,19 @@ async function forEachCleanupChunk( } } - if (housekeepingPlan && housekeepingPlan !== 'enterprise' && !housekeepingAssigned) { + /** + * Global housekeeping is keyed to a plan's default retention window, so it + * only makes sense where those plans exist. Emitting it with billing off + * would reach for the hosted free-tier window — the same 30 days the + * per-workspace pass deliberately refuses to apply — and act on it, which is + * exactly the rule `resolvePlanTypesByWorkspaceId` exists to enforce. + */ + if ( + isBillingEnabled && + housekeepingPlan && + housekeepingPlan !== 'enterprise' && + !housekeepingAssigned + ) { const retentionHours = config.defaults[housekeepingPlan] if (retentionHours != null) { await emitChunk({ diff --git a/apps/sim/lib/workspaces/organization-workspaces.ts b/apps/sim/lib/workspaces/organization-workspaces.ts index 428610ac014..79a8b1a8ee8 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.ts @@ -335,6 +335,22 @@ export async function attachOwnedWorkspacesToOrganizationTx( export async function detachOrganizationWorkspaces( organizationId: string +): Promise { + return db.transaction((tx) => detachOrganizationWorkspacesTx(tx, organizationId)) +} + +/** + * Transaction-enlisted detach, for callers that must commit the detach together + * with something else — deleting the organization, for instance, where a + * detach that committed on its own would leave workspaces re-billed while the + * organization it was meant to empty still exists. + * + * Audit rows are emitted by the caller after commit; `recordAuditBatch` is + * fire-and-forget and must not describe a transaction that may still roll back. + */ +export async function detachOrganizationWorkspacesTx( + tx: DbOrTx, + organizationId: string ): Promise { const organizationOwnerId = await getOrganizationOwnerId(organizationId) if (!organizationOwnerId) { @@ -344,7 +360,7 @@ export async function detachOrganizationWorkspaces( ) } - const organizationWorkspaces = await db + const organizationWorkspaces = await tx .select({ id: workspace.id, ownerId: workspace.ownerId, @@ -358,7 +374,7 @@ export async function detachOrganizationWorkspaces( ) ) - const detachedWorkspaceIds = await db.transaction(async (tx) => { + const detachedWorkspaceIds = await (async () => { const now = new Date() const workspaceIds = organizationWorkspaces .map((organizationWorkspace) => organizationWorkspace.id) @@ -409,7 +425,7 @@ export async function detachOrganizationWorkspaces( }) return [...workspaceIds].sort() - }) + })() const workspacesById = new Map( organizationWorkspaces.map((organizationWorkspace) => [ From 29ab3d0d60994d72659146575a77c9862ca5eefb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 18:32:54 -0700 Subject: [PATCH 8/9] fix(admin): gate whitelabel on entitlement and emit detach audits post-commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Admin whitelabel PATCH skipped the entitlement check the settings UI runs, so an admin key could set branding the product had not granted the org. detachOrganizationWorkspacesTx also wrote its audit rows inside the caller's transaction, contradicting its own doc comment — a rolled-back delete would have left audit history describing detachments that never happened. It now returns the rows and callers emit them after commit. --- .../v1/admin/organizations/[id]/route.test.ts | 3 ++ .../api/v1/admin/organizations/[id]/route.ts | 6 ++- .../organizations/[id]/whitelabel/route.ts | 17 +++++++++ .../lib/workspaces/organization-workspaces.ts | 38 ++++++++++--------- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts index f8ebca64ee0..216d88bbd71 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts @@ -22,6 +22,7 @@ vi.mock('@/app/api/v1/admin/auth', () => ({ vi.mock('@sim/audit', () => ({ recordAudit: vi.fn(), + recordAuditBatch: vi.fn(), AuditAction: { ORGANIZATION_UPDATED: 'organization.updated', ORGANIZATION_DELETED: 'organization.deleted', @@ -57,6 +58,8 @@ describe('admin organization DELETE', () => { mockDetachOrganizationWorkspacesTx.mockResolvedValue({ detachedWorkspaceIds: ['ws-1', 'ws-2'], billedAccountUserId: 'user-1', + /** Returned rather than written, so the caller can emit them post-commit. */ + auditEntries: [], }) mockDelete.mockClear() }) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 4b56bd9fea4..d8e1c9c450c 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -30,7 +30,7 @@ * Response: AdminSingleResponse<{ success, organizationId, slug, membersRemoved, workspacesDetached }> */ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { AuditAction, AuditResourceType, recordAudit, recordAuditBatch } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -292,12 +292,14 @@ export const DELETE = withRouteHandler( * leave workspaces re-billed to their owners while the organization, * its members, and its settings survived a failed delete. */ - const { detachedWorkspaceIds } = await db.transaction(async (tx) => { + const { detachedWorkspaceIds, auditEntries } = await db.transaction(async (tx) => { const detached = await detachOrganizationWorkspacesTx(tx, organizationId) await tx.delete(organization).where(eq(organization.id, organizationId)) return detached }) + recordAuditBatch(auditEntries) + logger.info(`Admin API: Deleted organization ${organizationId}`, { slug: existing.slug, membersRemoved: memberCountRow?.value ?? 0, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts index f6ebbdce562..91f048db597 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts @@ -18,12 +18,15 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { adminV1UpdateOrganizationWhitelabelContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' import type { OrganizationWhitelabelSettings } from '@/lib/branding/types' +import { isBillingEnabled, isWhitelabelingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, adminValidationErrorResponse, + forbiddenResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -62,6 +65,20 @@ export const PATCH = withRouteHandler( return notFoundResponse('Organization') } + /** + * Same entitlement gate the settings UI applies. An admin key + * authenticates an operator, not an entitlement, so without this it would + * be a way to set branding the product has not granted this organization. + */ + const entitled = await isOrganizationFeatureEntitled(organizationId, isWhitelabelingEnabled) + if (!entitled) { + return forbiddenResponse( + isBillingEnabled + ? 'Whitelabeling is available on Enterprise plans only' + : 'Whitelabeling is disabled. Set ENTERPRISE_ENABLED or WHITELABELING_ENABLED to enable it.' + ) + } + const merged: OrganizationWhitelabelSettings = { ...(existing.whitelabelSettings ?? {}) } for (const key of Object.keys(incoming) as Array) { const value = incoming[key] diff --git a/apps/sim/lib/workspaces/organization-workspaces.ts b/apps/sim/lib/workspaces/organization-workspaces.ts index 79a8b1a8ee8..3d51f1a88c0 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.ts @@ -32,6 +32,8 @@ export interface AttachOwnedWorkspacesToOrganizationTxResult export interface DetachOrganizationWorkspacesResult { detachedWorkspaceIds: string[] billedAccountUserId: string | null + /** Emit with `recordAuditBatch` once the surrounding transaction has committed. */ + auditEntries: Parameters[0] } export class WorkspaceOrganizationMembershipConflictError extends Error { @@ -336,7 +338,9 @@ export async function attachOwnedWorkspacesToOrganizationTx( export async function detachOrganizationWorkspaces( organizationId: string ): Promise { - return db.transaction((tx) => detachOrganizationWorkspacesTx(tx, organizationId)) + const result = await db.transaction((tx) => detachOrganizationWorkspacesTx(tx, organizationId)) + recordAuditBatch(result.auditEntries) + return result } /** @@ -345,8 +349,10 @@ export async function detachOrganizationWorkspaces( * detach that committed on its own would leave workspaces re-billed while the * organization it was meant to empty still exists. * - * Audit rows are emitted by the caller after commit; `recordAuditBatch` is - * fire-and-forget and must not describe a transaction that may still roll back. + * Returns its audit rows in `auditEntries` rather than writing them. Callers + * pass them to `recordAuditBatch` only after their transaction commits: the + * write is fire-and-forget, so emitting it here would leave audit history + * describing detachments that a later rollback undid. */ export async function detachOrganizationWorkspacesTx( tx: DbOrTx, @@ -427,14 +433,23 @@ export async function detachOrganizationWorkspacesTx( return [...workspaceIds].sort() })() + logger.info('Detached organization workspaces', { + organizationId, + detachedWorkspaceCount: detachedWorkspaceIds.length, + billedAccountUserId: organizationOwnerId, + }) + const workspacesById = new Map( organizationWorkspaces.map((organizationWorkspace) => [ organizationWorkspace.id, organizationWorkspace, ]) ) - recordAuditBatch( - detachedWorkspaceIds.map((detachedWorkspaceId) => { + + return { + detachedWorkspaceIds, + billedAccountUserId: organizationOwnerId, + auditEntries: detachedWorkspaceIds.map((detachedWorkspaceId) => { const detachedWorkspace = workspacesById.get(detachedWorkspaceId) return { workspaceId: detachedWorkspaceId, @@ -450,17 +465,6 @@ export async function detachOrganizationWorkspacesTx( newBilledAccountUserId: organizationOwnerId ?? detachedWorkspace?.ownerId ?? null, }, } - }) - ) - - logger.info('Detached organization workspaces', { - organizationId, - detachedWorkspaceCount: detachedWorkspaceIds.length, - billedAccountUserId: organizationOwnerId, - }) - - return { - detachedWorkspaceIds, - billedAccountUserId: organizationOwnerId, + }), } } From 3390208674010e8da07d0b2a05f8aea0107b523f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 28 Jul 2026 18:42:35 -0700 Subject: [PATCH 9/9] fix(self-host): refuse instance-org resolution when the slug is ambiguous organization.slug has no unique constraint, and the lookup took the first of however many matched. The choice is unordered, so two replicas could resolve different organizations and split new signups between them. Resolution is now three-state. Ambiguity is distinct from absence, so it both declines to adopt an arbitrary organization and declines to provision another one on top of the duplicates. --- .../lib/organizations/instance-org.test.ts | 16 ++++++ apps/sim/lib/organizations/instance-org.ts | 57 +++++++++++++++---- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/organizations/instance-org.test.ts b/apps/sim/lib/organizations/instance-org.test.ts index ff8157fb653..29d7c670f55 100644 --- a/apps/sim/lib/organizations/instance-org.test.ts +++ b/apps/sim/lib/organizations/instance-org.test.ts @@ -141,6 +141,22 @@ describe('instance organization', () => { expect(mockCreateOrganizationWithOwnerTx).not.toHaveBeenCalled() }) + it('refuses when more than one organization shares the slug', async () => { + /** + * `organization.slug` has no unique constraint. Picking one of several + * is unordered, so replicas could disagree and split signups across two + * organizations — worse than declining until the operator disambiguates. + */ + queueRows([{ id: 'org_a' }, { id: 'org_b' }]) // pre-transaction lookup + queueRows([{ id: 'org_a' }, { id: 'org_b' }]) // re-check under the lock + + const result = await ensureInstanceOrganization('user-1') + + expect(result).toBeNull() + /** Must not add a third row to a set the operator already has to untangle. */ + expect(mockCreateOrganizationWithOwnerTx).not.toHaveBeenCalled() + }) + it('takes the advisory lock before creating', async () => { queueRows([]) queueRows([]) diff --git a/apps/sim/lib/organizations/instance-org.ts b/apps/sim/lib/organizations/instance-org.ts index 09abdaccb3a..bbfddff234c 100644 --- a/apps/sim/lib/organizations/instance-org.ts +++ b/apps/sim/lib/organizations/instance-org.ts @@ -26,6 +26,7 @@ import { } from '@/lib/billing/organizations/create-organization' import { env } from '@/lib/core/config/env' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import type { DbOrTx } from '@/lib/db/types' const logger = createLogger('InstanceOrganization') @@ -101,13 +102,48 @@ export async function getInstanceOrganizationId(): Promise { const config = getInstanceOrganizationConfig() if (!config) return null - const [row] = await db + const resolved = await resolveInstanceOrganizationBySlug(db, config.slug) + return resolved.status === 'found' ? resolved.organizationId : null +} + +/** + * Resolves the single organization holding this slug. + * + * Matching on slug is deliberate — it is what lets the mode adopt an + * organization that already exists, such as one the consolidate script created + * before `INSTANCE_ORG_NAME` was set. But `organization.slug` carries no unique + * constraint, so duplicates are possible, and taking the first of several would + * be worse than wrong: the choice is unordered, so two replicas could resolve + * different organizations and split new signups between them. + * + * Refuses instead. Instance-organization mode stays off until the operator + * renames the duplicate or pins `INSTANCE_ORG_SLUG` at the one they mean, which + * is recoverable — silently sorting users into two organizations is not. + */ +type SlugResolution = + | { status: 'found'; organizationId: string } + | { status: 'none' } + | { status: 'ambiguous' } + +async function resolveInstanceOrganizationBySlug( + executor: DbOrTx, + slug: string +): Promise { + const rows = await executor .select({ id: organization.id }) .from(organization) - .where(eq(organization.slug, config.slug)) - .limit(1) + .where(eq(organization.slug, slug)) + .limit(2) - return row?.id ?? null + if (rows.length > 1) { + logger.error( + 'Refusing to resolve the instance organization: more than one organization uses this slug. Rename the duplicate or set INSTANCE_ORG_SLUG to the intended one.', + { slug } + ) + return { status: 'ambiguous' } + } + + return rows[0] ? { status: 'found', organizationId: rows[0].id } : { status: 'none' } } /** @@ -178,12 +214,13 @@ export async function ensureInstanceOrganization( sql`select pg_advisory_xact_lock(hashtextextended(${`instance-organization:${config.slug}`}, 0))` ) - const [row] = await tx - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.slug, config.slug)) - .limit(1) - if (row) return row.id + const resolved = await resolveInstanceOrganizationBySlug(tx, config.slug) + if (resolved.status === 'found') return resolved.organizationId + /** + * Never create while the slug is ambiguous — that would add a third row + * to a set the operator already has to untangle. + */ + if (resolved.status === 'ambiguous') return null /** * The owner must not already belong to another organization — a user can