Files
openwork/docs/enterprise-plan-gating.md

170 lines
7.5 KiB
Markdown

# Enterprise Plan Gating: SSO + Desktop Policies
Status: implemented behind `DEN_PLAN_GATING_ENABLED` (default off)
Owner: growth/enterprise
Related: `docs/desktop-app-policies.md`, `ee/apps/den-api/src/sso.ts`, `ee/apps/den-api/src/routes/org/desktop-policies.ts`
## Goal
Move SSO/SAML and Desktop Policies into an Enterprise plan without breaking any
org that already uses them, and package the offering so it is attractive to
companies (managed deployment, skill development, MCP consulting).
## Packaging
| Plan | Price | Includes |
|---|---|---|
| Solo | $0 | Open-source desktop app, BYO keys |
| Team Starter | $50/mo | 5 seats, API access, marketplace/plugin management, distributed keys |
| Enterprise | Custom | Everything in Team, plus: SSO/SAML + SCIM, Desktop policies & version controls, enforced SSO (`requireSso`), managed deployment (self-hosted or hosted by us), custom skill development, MCP consulting, rollout support, custom commercial terms |
Services (sold with Enterprise, delivered by us):
- **Managed deployment** — we run Den (or guide a self-hosted install), wire up
the customer's gateway/LiteLLM, IdP, and version pinning.
- **Skill development** — we build org-specific skill plugins and marketplace packs for
the customer's workflows.
- **MCP consulting** — we connect internal data sources and tools as MCP
servers, with policy guardrails.
## What gets gated (and what never does)
Principle: **gate management (writes), never delivery (reads) or removal
(deletes).** An org that loses entitlement keeps working; it just can't add or
edit enterprise configuration.
Gated (require `enterprise` entitlement, return HTTP 402):
- `POST /v1/sso/saml`, `POST /v1/sso/oidc` — register/replace SSO connection
(`ee/apps/den-api/src/routes/org/sso.ts`)
- `POST /v1/sso/request-domain-verification`, `POST /v1/sso/verify-domain`
- `POST /v1/desktop-policies`, `PATCH /v1/desktop-policies/:id` — create/edit
policies and assignments (`routes/org/desktop-policies.ts`)
- `PATCH /v1/org` **only when the patch touches** `requireSso` or
`allowedDesktopVersions` (`routes/org/core.ts` → `orgs.ts:updateOrganizationSettings`)
Never gated:
- SSO **sign-in** for end users: `/sso/<orgSlug>`, `GET /v1/orgs/sso/resolve`,
ACS/callback routes. Existing connections keep authenticating users forever.
- `GET /v1/me/desktop-config` — policies already defined keep being delivered
to and enforced by the desktop app.
- All `GET` endpoints (admins can always see their config).
- All `DELETE` endpoints (`DELETE /v1/sso`, `DELETE /v1/desktop-policies/:id`)
— downgrading/removing must always be possible.
- SCIM token usage for an already-provisioned connection.
This guarantees zero breakage: nothing that runs today stops running. The only
new friction is on *changing* enterprise config without a plan.
## Plan model
Follow the existing org-metadata pattern (`organization-limits.ts` already
stores `limits`, `requireSso`, `allowedDesktopVersions`, `inference` there).
```ts
// organization.metadata
{
plan?: {
tier: "free" | "team" | "enterprise"
source: "default" | "stripe" | "manual" | "grandfathered"
grandfatheredAt?: string // ISO date, set by backfill
}
}
```
New module `ee/apps/den-api/src/entitlements.ts`:
```ts
type EntitlementKey = "sso" | "desktopPolicies" | "desktopVersionPinning" | "requireSso"
function getOrganizationEntitlements(org): Record<EntitlementKey, boolean>
// tier === "enterprise" (any source) => all true; otherwise all false.
function requireEntitlement(org, key): void
// throws HTTPException 402 { error: "enterprise_plan_required", feature: key }
```
Reuse the existing 402 pattern from seat gating
(`routes/org/invitations.ts:136-148`, error `seat_subscription_required`).
### Kill switch
`DEN_PLAN_GATING_ENABLED` env var (mirrors `POLAR_FEATURE_GATE_ENABLED` in
`env.ts` / `billing/polar.ts`). Default **off**:
- Hosted Den: we flip it on after the backfill runs.
- Self-hosted installs: stays off unless the operator opts in, so the
open-source/ejectable story is unchanged. (The code already lives under
`/ee` (FSL-1.1-MIT), which is the licensing boundary for these features.)
## Grandfathering (no-breakage migration, CI-applied)
Drizzle data migration
`ee/packages/den-db/drizzle/0023_grandfather_enterprise_plans.sql`, applied
automatically by the existing `den-db-migrate.yml` workflow when it lands on
`dev` — no manual step. It runs exactly once (tracked in
`__drizzle_migrations`) and is idempotent by construction. An org is
grandfathered to `plan: { tier: "enterprise", source: "grandfathered",
grandfatheredAt }` if any of:
1. A row exists in `sso_connection` for the org (any status), or
2. It has any non-default desktop policy, or a default policy whose values
differ from the catalog defaults, or any `desktop_policy_member` rows, or
3. Org metadata has `requireSso === true` or non-empty `allowedDesktopVersions`.
Orgs already on the enterprise tier (e.g. `source: "manual"`) are left
untouched.
Grandfathered orgs get **full** enterprise entitlements indefinitely — they can
keep editing, not just keep running. They are flagged (`source:
"grandfathered"`) so sales can see them in the admin backoffice and reach out
at renewal time; we never auto-expire them.
Rollout order (each step independently safe):
1. Merge: entitlement code ships dark (`DEN_PLAN_GATING_ENABLED` unset) and CI
applies the grandfathering migration in the same deploy.
2. Update landing/pricing pages (separate PR).
3. Enable the flag on hosted Den. Do this soon after the merge: orgs that
first adopt SSO/policies *between* the migration and the flag flip are not
grandfathered (acceptable — gating only blocks edits, never breaks what
they configured; ship a follow-up data migration if the gap grows long).
4. Manual plan assignment for new enterprise customers via existing instance
admin surface (`routes/admin/index.ts`) until Stripe enterprise products
exist; later, optionally a third `OrgSubscriptionTable.type = "enterprise"`.
## UX when gated
- **den-web dashboard** (`dashboard/sso/`, `dashboard/(admin)/desktop-policies/`,
org settings requireSso/version toggles): screens stay visible but show an
"Enterprise" badge and a "Talk to us" CTA (→ `openworklabs.com/enterprise#book`)
instead of the create/edit actions, driven by an `entitlements` object added
to the existing org payload in `org-dashboard-provider.tsx`. Handle the 402
defensively as well.
- **Desktop app**: no changes. `DesktopConfigProvider` and restriction notices
(`restriction-notice-provider.tsx`) keep working for orgs whose policies were
set while entitled or grandfathered.
## Explaining desktop policies (positioning)
Desktop policies are the "managed desktop controls" pillar of Enterprise.
End-user messaging already exists (`userNotice` per policy in
`packages/types/src/den/desktop-policies.ts` + restriction notice modal); the
marketing story is:
> Admins decide which providers, models, extensions, and app versions employees
> can use. The desktop app enforces it automatically — no MDM scripting.
This is reflected on `/pricing` and `/enterprise` as "Desktop policies &
version controls".
## Open questions
- Do we gate SCIM token *generation* too? (Suggested: yes, same entitlement as
SSO, since it's part of identity provisioning.)
- Enterprise self-serve checkout vs. sales-led only (suggested: sales-led
first; the CTA is already a Cal.com booking).
- Whether Team Starter should eventually include a single OIDC connection as a
mid-tier hook (defer).