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/(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/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..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,6 +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 { + 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' @@ -65,27 +69,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 +208,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 } @@ -260,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/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..355e49f0d55 --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts @@ -0,0 +1,153 @@ +/** + * 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 { + 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, + 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 + } + + /** + * 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() }) + .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..216d88bbd71 --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDetachOrganizationWorkspacesTx, mockDelete, mockAuthenticateAdminRequest } = vi.hoisted( + () => ({ + mockDetachOrganizationWorkspacesTx: vi.fn(), + mockDelete: vi.fn(), + mockAuthenticateAdminRequest: vi.fn(), + }) +) + +vi.mock('@/lib/workspaces/organization-workspaces', () => ({ + detachOrganizationWorkspacesTx: mockDetachOrganizationWorkspacesTx, +})) + +vi.mock('@/app/api/v1/admin/auth', () => ({ + authenticateAdminRequest: mockAuthenticateAdminRequest, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + recordAuditBatch: 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 }) + 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() + }) + + 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(mockDetachOrganizationWorkspacesTx).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(mockDetachOrganizationWorkspacesTx).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(mockDetachOrganizationWorkspacesTx).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(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID) + }) + + 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. The transaction is passed + * through so the detach and the delete commit together. + */ + expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), 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..d8e1c9c450c 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -14,14 +14,29 @@ * - 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' +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' -import { and, count, eq, inArray } from 'drizzle-orm' +import { and, count, eq, inArray, isNull, not, or } from 'drizzle-orm' import { + adminV1DeleteOrganizationContract, adminV1GetOrganizationContract, adminV1UpdateOrganizationContract, } from '@/lib/api/contracts/v1/admin' @@ -32,13 +47,18 @@ 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 { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, + conflictResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -191,3 +211,127 @@ 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. + * + * 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 }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + or( + isNull(subscription.status), + not(inArray(subscription.status, TERMINAL_SUBSCRIPTION_STATUSES)) + ) + ) + ) + .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. + * + * 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, 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, + 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..c38a26c08f4 --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/session-policy/route.ts @@ -0,0 +1,137 @@ +/** + * 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 { 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, +} 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') + } + + /** + * 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, + } + + /** + * 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..91f048db597 --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/whitelabel/route.ts @@ -0,0 +1,119 @@ +/** + * 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 { 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, +} 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') + } + + /** + * 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] + 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/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/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/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/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-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()] : []), ], }) 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..e1d3f8977ac 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,65 @@ 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() + /** + * 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 78a97e4dd72..8b8608d63a4 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( @@ -274,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({ @@ -302,11 +330,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..d59d0814d66 100644 --- a/apps/sim/lib/billing/retention.test.ts +++ b/apps/sim/lib/billing/retention.test.ts @@ -2,9 +2,12 @@ * @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, } from '@/lib/billing/retention' @@ -355,3 +358,161 @@ 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() + }) +}) + +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 d36959e4c1c..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, @@ -140,3 +143,116 @@ 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 +} + +/** + * 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 +} 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. */ 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..29d7c670f55 --- /dev/null +++ b/apps/sim/lib/organizations/instance-org.test.ts @@ -0,0 +1,248 @@ +/** + * @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, +} 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 + 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('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([]) + 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('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 + + queueRows([]) + queueRows([]) + queueRows([]) + mockCreateOrganizationWithOwnerTx.mockResolvedValue({ organizationId: 'org_recreated' }) + const recreated = await ensureInstanceOrganization('user-2') + + expect(mockSelect.mock.calls.length).toBeGreaterThan(callsAfterFirst) + expect(recreated).toBe('org_recreated') + }) + }) + + 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..bbfddff234c --- /dev/null +++ b/apps/sim/lib/organizations/instance-org.ts @@ -0,0 +1,324 @@ +/** + * 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' +import type { DbOrTx } from '@/lib/db/types' + +const logger = createLogger('InstanceOrganization') + +/** Bounds the wait for a concurrent provisioning attempt on another replica. */ +const INSTANCE_ORG_LOCK_TIMEOUT_MS = 10_000 + +/** 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. + * + * 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 { + const config = getInstanceOrganizationConfig() + if (!config) return null + + 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, slug)) + .limit(2) + + 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' } +} + +/** + * 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 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 + * 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 + }) + + 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), + }) + } +} diff --git a/apps/sim/lib/workspaces/organization-workspaces.ts b/apps/sim/lib/workspaces/organization-workspaces.ts index 428610ac014..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 { @@ -335,6 +337,26 @@ export async function attachOwnedWorkspacesToOrganizationTx( export async function detachOrganizationWorkspaces( organizationId: string +): Promise { + const result = await db.transaction((tx) => detachOrganizationWorkspacesTx(tx, organizationId)) + recordAuditBatch(result.auditEntries) + return result +} + +/** + * 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. + * + * 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, + organizationId: string ): Promise { const organizationOwnerId = await getOrganizationOwnerId(organizationId) if (!organizationOwnerId) { @@ -344,7 +366,7 @@ export async function detachOrganizationWorkspaces( ) } - const organizationWorkspaces = await db + const organizationWorkspaces = await tx .select({ id: workspace.id, ownerId: workspace.ownerId, @@ -358,7 +380,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,6 +431,12 @@ export async function detachOrganizationWorkspaces( }) return [...workspaceIds].sort() + })() + + logger.info('Detached organization workspaces', { + organizationId, + detachedWorkspaceCount: detachedWorkspaceIds.length, + billedAccountUserId: organizationOwnerId, }) const workspacesById = new Map( @@ -417,8 +445,11 @@ export async function detachOrganizationWorkspaces( organizationWorkspace, ]) ) - recordAuditBatch( - detachedWorkspaceIds.map((detachedWorkspaceId) => { + + return { + detachedWorkspaceIds, + billedAccountUserId: organizationOwnerId, + auditEntries: detachedWorkspaceIds.map((detachedWorkspaceId) => { const detachedWorkspace = workspacesById.get(detachedWorkspaceId) return { workspaceId: detachedWorkspaceId, @@ -434,17 +465,6 @@ export async function detachOrganizationWorkspaces( newBilledAccountUserId: organizationOwnerId ?? detachedWorkspace?.ownerId ?? null, }, } - }) - ) - - logger.info('Detached organization workspaces', { - organizationId, - detachedWorkspaceCount: detachedWorkspaceIds.length, - billedAccountUserId: organizationOwnerId, - }) - - return { - detachedWorkspaceIds, - billedAccountUserId: organizationOwnerId, + }), } } 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 95429efbd64..2de8ab923ef 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/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 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 1e073231776..7d364ffe8d8 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: 981, - zodRoutes: 981, + totalRoutes: 984, + zodRoutes: 984, 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: '' },