From 951424d4ff954b79f2cd5e5d1e55fe41d6cd53e0 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:19:40 +0100 Subject: [PATCH] Keep the provider portal up when the MSP licence lapses A provider-hosted control plane refused to start once its licence was past expiry and the 7-day grace, because startup validated it with ValidateLicense. So at the next restart after a 60-day evaluation ran out, or after a paying provider's subscription lapsed, the portal went down, and the portal's Plan tab is the only place a provider can buy or renew. Reproduced on the walkthrough lab. Startup now accepts an authentic, key-bound licence even when lapsed (ValidateLicenseAllowingLapse), preferring a current licence and otherwise the one that expired later, and logs the lapse. It unlocks nothing: client runtimes still verify the provider licence in every lease with ValidateLicense and drop MSP capabilities, new clients are refused with provider_msp_license_lapsed, and the refresher still refuses a lapsed licence. The Plan panel says the evaluation or plan ended and offers the plans, and provider-msp status reports license_lapsed without failing so a lapsed install can still upgrade. The same run showed the portal printing control plane error codes such as provider_msp_license_lapsed instead of their message. The portal now prefers an error's message over its code, which also fixes already_subscribed and checkout_unavailable in the Plan tab. --- cmd/pulse-control-plane/provider_msp_proof.go | 5 +- .../provider_msp_status.go | 23 ++++-- .../provider_msp_status_test.go | 24 ++++++ .../v6/internal/subsystems/cloud-paid.md | 34 ++++++++ .../subsystems/deployment-installability.md | 14 ++++ internal/cloudcp/account/tenant_handlers.go | 11 +++ .../cloudcp/account/tenant_handlers_test.go | 39 +++++++++ internal/cloudcp/config.go | 82 +++++++++++++++---- internal/cloudcp/config_test.go | 45 ++++++++++ .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.js | 32 ++++++-- .../portal/frontend/src/account_runtime.ts | 4 +- .../cloudcp/portal/frontend/src/api.test.ts | 25 ++++++ internal/cloudcp/portal/frontend/src/api.ts | 11 ++- .../portal/frontend/src/provider_plan.test.ts | 25 ++++++ .../portal/frontend/src/provider_plan.ts | 33 +++++++- .../cloudcp/provider_msp_license_refresh.go | 2 + .../provider_msp_license_refresh_test.go | 20 +++++ internal/cloudcp/routes.go | 1 + pkg/licensing/cloud_paid_guardrails_test.go | 50 ++++++++++- pkg/licensing/service.go | 17 ++++ 21 files changed, 455 insertions(+), 44 deletions(-) diff --git a/cmd/pulse-control-plane/provider_msp_proof.go b/cmd/pulse-control-plane/provider_msp_proof.go index 3a02cc09c..bb750dba3 100644 --- a/cmd/pulse-control-plane/provider_msp_proof.go +++ b/cmd/pulse-control-plane/provider_msp_proof.go @@ -436,8 +436,9 @@ func (rt *providerMSPProofRuntime) createProviderMSPProofWorkspace(ctx context.C rt.registry, rt.provisioner, account.WorkspaceLimitPolicy{ - ProviderHostedMSP: true, - ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg), + ProviderHostedMSP: true, + ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg), + ProviderMSPLicenseLapsed: func() bool { return rt.cfg.ProviderMSPLicenseLapsed(time.Now()) }, }, ) mux := http.NewServeMux() diff --git a/cmd/pulse-control-plane/provider_msp_status.go b/cmd/pulse-control-plane/provider_msp_status.go index 0dfe73f80..3dc8421b2 100644 --- a/cmd/pulse-control-plane/provider_msp_status.go +++ b/cmd/pulse-control-plane/provider_msp_status.go @@ -23,15 +23,18 @@ type providerMSPStatusOptions struct { } type providerMSPStatusReport struct { - OK bool - Environment string - ControlMode string - BaseURL string - PlanVersion string - PlanSource string - LicenseID string - LicenseEmail string - WorkspaceLimit int + OK bool + Environment string + ControlMode string + BaseURL string + PlanVersion string + PlanSource string + LicenseID string + LicenseEmail string + WorkspaceLimit int + // LicenseLapsed is informational, not a failure: a lapsed install must + // still upgrade and keep its portal up so the provider can renew there. + LicenseLapsed bool RegistryReady bool TotalTenants int HealthyTenants int @@ -117,6 +120,7 @@ func runProviderMSPStatusWithDependencies(ctx context.Context, cfg *cloudcp.CPCo LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID), LicenseEmail: strings.ToLower(strings.TrimSpace(cfg.ProviderMSPLicenseEmail)), WorkspaceLimit: workspaceLimit, + LicenseLapsed: cfg.ProviderMSPLicenseLapsed(deps.Now()), CountsByState: map[registry.TenantState]int{}, } addFailure := func(format string, args ...any) { @@ -399,6 +403,7 @@ func printProviderMSPStatusReport(report *providerMSPStatusReport) { fmt.Printf("license_id=%s\n", report.LicenseID) fmt.Printf("license_email=%s\n", report.LicenseEmail) fmt.Printf("workspace_limit=%d\n", report.WorkspaceLimit) + fmt.Printf("license_lapsed=%t\n", report.LicenseLapsed) fmt.Printf("registry_ready=%t\n", report.RegistryReady) fmt.Printf("total_tenants=%d\n", report.TotalTenants) fmt.Printf("healthy_tenants=%d\n", report.HealthyTenants) diff --git a/cmd/pulse-control-plane/provider_msp_status_test.go b/cmd/pulse-control-plane/provider_msp_status_test.go index e6ea84fa7..ef01aada9 100644 --- a/cmd/pulse-control-plane/provider_msp_status_test.go +++ b/cmd/pulse-control-plane/provider_msp_status_test.go @@ -73,6 +73,30 @@ func TestProviderMSPStatusReportsHealthyOperatorSurface(t *testing.T) { } } +// A lapsed licence is reported, not failed: upgrade.sh gates on status, and a +// lapsed install must still upgrade and keep its portal up to renew. +func TestProviderMSPStatusReportsALapsedLicenceWithoutFailing(t *testing.T) { + cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile) + now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) + cfg.ProviderMSPLicenseExpiresAt = now.Add(-30 * 24 * time.Hour) + report, err := runProviderMSPStatusWithDependencies(context.Background(), cfg, providerMSPStatusOptions{}, providerMSPStatusDependencies{ + RunPreflight: func(context.Context, *cloudcp.CPConfig, providerMSPPreflightOptions) (*providerMSPPreflightReport, error) { + return healthyProviderMSPStatusPreflightReport(), nil + }, + NewDocker: healthyProviderMSPStatusDocker(map[string]bool{}), + CheckBackup: func(context.Context, *cloudcp.CPConfig) (*providerMSPBackupStatus, error) { + return healthyProviderMSPBackupStatus(now), nil + }, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("runProviderMSPStatusWithDependencies: %v", err) + } + if !report.OK || !report.LicenseLapsed { + t.Fatalf("report OK=%v LicenseLapsed=%v failures=%v, want OK and lapsed", report.OK, report.LicenseLapsed, report.Failures) + } +} + func TestProviderMSPStatusFailsOnFailedUnhealthyAndStuckWorkspaces(t *testing.T) { cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile) now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 1a582b52b..65b8b37ac 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3455,6 +3455,40 @@ Regression coverage: `TestEnsureTenantNetworkRejectsUnownedExistingNetwork` in `internal/cloudcp/docker/manager_test.go`. +### A provider control plane keeps its portal up on a lapsed licence + +A provider-hosted control plane used to refuse to start once its licence was +past expiry and the 7-day grace: `ValidateLicense` rejected it, so `LoadConfig` +failed and the portal went down at the next restart. That is exactly when the +provider needs the portal, because Plan is the only place to buy or renew; +the same happened to a paying provider whose subscription lapsed. Startup now +resolves an authentic, key-bound licence with +`pkglicensing.ValidateLicenseAllowingLapse`, preferring a current licence +(renewed first) and otherwise the lapsed one that expired later, and logs the +lapse. Nothing is unlocked by this: client runtimes still verify the provider +licence carried in each lease with `ValidateLicense` and drop MSP capabilities +once it lapses, workspace creation refuses with `provider_msp_license_lapsed`, +and the refresher still refuses to adopt a lapsed licence from the licence +server. The Plan panel reads `lapsed` from the plan state, says the evaluation +or plan ended and what that means for clients, and offers the plans, including +the same plan again after a paid plan ends; `provider-msp status` prints +`license_lapsed` without failing, so a lapsed install can still upgrade. The +portal also shows a control plane error's `message` in preference to its +machine `error` code, which had been surfacing codes such as +`provider_msp_license_lapsed` and `already_subscribed` as the whole message. +Verified on 2026-09-24 against the walkthrough lab: the control plane started +on an evaluation that lapsed 20 days earlier, the Plan panel offered Solo and +Starter, adding a client showed the lapse sentence, and with a live +subscription the refresher restored the paid licence on its own. Regression +coverage: `TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence` +in `pkg/licensing/service_lapsed_test.go`, +`TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence` in +`internal/cloudcp/config_test.go`, +`TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence` in +`internal/cloudcp/account/tenant_handlers_lapsed_test.go`, and +`TestProviderMSPLicenseRefreshRefusesALapsedLicence` in +`internal/cloudcp/provider_msp_license_refresh_test.go`. + ### Provider-hosted MSP platforms buy and renew their own licence A provider-hosted control plane now buys and renews its licence through the diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 51d91a867..efcb09cec 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2483,6 +2483,20 @@ unchanged. Regression coverage: `TestProviderMSPInstallProofCleansUpWorkspacesFromAPartialProof` in `cmd/pulse-control-plane/provider_msp_install_proof_test.go`. +### A lapsed provider MSP install still starts and upgrades + +A provider-hosted install whose licence is past expiry and grace (an +evaluation that ran out, or a paid plan that was not renewed) now starts its +control plane instead of crash-looping on licence validation, so the portal +and its Plan tab stay reachable to buy or renew. `provider-msp status`, which +`upgrade.sh` gates on, prints `license_lapsed=true` as information rather than +a failure, so such an install can still be upgraded. Client runtimes keep +enforcing the lapse themselves and new clients are refused until a current +licence is in place. Verified on 2026-09-24 on the walkthrough lab with an +evaluation that lapsed 20 days earlier. Regression coverage: +`TestProviderMSPStatusReportsALapsedLicenceWithoutFailing` in +`cmd/pulse-control-plane/provider_msp_status_test.go`. + ### Provider MSP setup points buyers at the portal `deploy/provider-msp/setup.sh` no longer tells an evaluating provider to diff --git a/internal/cloudcp/account/tenant_handlers.go b/internal/cloudcp/account/tenant_handlers.go index 3d121917b..c29a355cf 100644 --- a/internal/cloudcp/account/tenant_handlers.go +++ b/internal/cloudcp/account/tenant_handlers.go @@ -36,6 +36,10 @@ type ownerAwareWorkspaceProvisioner interface { type WorkspaceLimitPolicy struct { ProviderHostedMSP bool ProviderMSPPlanVersion string + // ProviderMSPLicenseLapsed reports whether the provider's licence is past + // its expiry and grace period. The portal keeps running then, so the + // provider can buy or renew, but no new client workspace may be created. + ProviderMSPLicenseLapsed func() bool } // HandleListTenants lists all tenants for an account. @@ -271,6 +275,13 @@ func enforceWorkspaceLimit(reg *registry.TenantRegistry, account *registry.Accou usingProviderHostedPlan := false if sa == nil { if policy.ProviderHostedMSP && account != nil && account.Kind == registry.AccountKindMSP { + if policy.ProviderMSPLicenseLapsed != nil && policy.ProviderMSPLicenseLapsed() { + return &workspaceLimitError{ + reason: "provider_msp_license_lapsed", + message: "Your Pulse MSP plan has ended, so no new clients can be added. Buy a plan from Plan in the portal to add clients again.", + statusCode: http.StatusForbidden, + } + } planVersion = pkglicensing.CanonicalizePlanVersion(policy.ProviderMSPPlanVersion) usingProviderHostedPlan = true if strings.TrimSpace(planVersion) == "" { diff --git a/internal/cloudcp/account/tenant_handlers_test.go b/internal/cloudcp/account/tenant_handlers_test.go index 420e1f8a5..b352717e8 100644 --- a/internal/cloudcp/account/tenant_handlers_test.go +++ b/internal/cloudcp/account/tenant_handlers_test.go @@ -698,3 +698,42 @@ func TestCreateWorkspace_BlockedWhenSubscriptionCanceled(t *testing.T) { t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusForbidden, rec.Body.String()) } } + +// A provider control plane keeps running on a lapsed licence so its portal can +// sell the renewal, but it must not add clients until the provider buys. +func TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence(t *testing.T) { + reg := newTestRegistry(t) + lapsed := true + mux, _ := newTestTenantMuxWithWorkspaceLimitPolicy( + t, + reg, + t.TempDir(), + WorkspaceLimitPolicy{ProviderHostedMSP: true, ProviderMSPPlanVersion: "msp_eval", ProviderMSPLicenseLapsed: func() bool { return lapsed }}, + cpstripe.WithDefaultMSPPlanVersion("msp_eval"), + ) + accountID, err := registry.GenerateAccountID() + if err != nil { + t.Fatal(err) + } + if err := reg.CreateAccount(®istry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Provider MSP"}); err != nil { + t.Fatal(err) + } + create := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/accounts/"+accountID+"/tenants", bytes.NewBufferString(`{"display_name":"Acme Dental"}`)) + return doRequest(t, mux, req) + } + + rec := create() + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Buy a plan from Plan") { + t.Fatalf("create on a lapsed licence = %d %q, want 403 pointing to Plan", rec.Code, rec.Body.String()) + } + if count, _ := reg.CountActiveByAccountID(accountID); count != 0 { + t.Fatalf("lapsed licence created %d workspaces", count) + } + + // Once the provider buys and the licence is current again, clients can be added. + lapsed = false + if rec := create(); rec.Code != http.StatusCreated { + t.Fatalf("create after renewal = %d %q, want 201", rec.Code, rec.Body.String()) + } +} diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 19be82762..0fce5e538 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -234,6 +234,13 @@ func LoadConfig() (*CPConfig, error) { providerMSPLicenseEmail = resolved.LicenseEmail providerMSPLicenseKey = resolved.LicenseKey providerMSPLeaseSigningPublicKey = resolved.LeaseSigningPublicKey + if resolved.lapsed(time.Now()) { + log.Warn(). + Str("license_id", resolved.LicenseID). + Str("plan_version", resolved.PlanVersion). + Time("expired_at", resolved.ExpiresAt). + Msg("Provider MSP license has lapsed: the portal stays up to sell the renewal, no new clients can be added, and client workspaces drop MSP capabilities") + } } cfg := &CPConfig{ @@ -626,7 +633,14 @@ type providerMSPLicenseResolution struct { ExpiresAt time.Time } +// resolveProviderMSPPlanFromLicenseFile validates a licence the control plane +// is about to adopt, such as one the licence server just returned. It refuses +// a lapsed licence. func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) { + return resolveProviderMSPLicenseFile(path, false) +} + +func resolveProviderMSPLicenseFile(path string, allowLapsed bool) (*providerMSPLicenseResolution, error) { path = strings.TrimSpace(path) if path == "" { return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required") @@ -636,7 +650,11 @@ func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseReso return nil, err } pkglicensing.InitEmbeddedPublicKey() - license, err := pkglicensing.ValidateLicense(licenseKey) + validate := pkglicensing.ValidateLicense + if allowLapsed { + validate = pkglicensing.ValidateLicenseAllowingLapse + } + license, err := validate(licenseKey) if err != nil { return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err) } @@ -689,26 +707,62 @@ func ProviderMSPRenewedLicensePath(dataDir string) string { return filepath.Join(dataDir, "control-plane", "provider-msp-license.jwt") } -// resolveProviderMSPLicense prefers a renewed licence that still validates -// and falls back to CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence is -// tried first because the host file is usually the self-issued evaluation, -// which expires; a paying provider must keep starting after it does. The -// lease signing key check in validate still ties either licence to this -// control plane's private key. +// lapsed reports whether the licence is past its expiry and grace period, the +// point at which client runtimes stop accepting leases chained to it. +func (r *providerMSPLicenseResolution) lapsed(now time.Time) bool { + return providerMSPLicenseLapsed(r.ExpiresAt, now) +} + +func providerMSPLicenseLapsed(expiresAt, now time.Time) bool { + return !expiresAt.IsZero() && now.After(expiresAt.Add(pkglicensing.DefaultGracePeriod)) +} + +// ProviderMSPLicenseLapsed reports whether this platform's licence is past its +// expiry and grace period. The control plane keeps running so its portal can +// sell the renewal, but it adds no clients, and client runtimes drop MSP +// capabilities on their own when they verify the lapsed licence. +func (c *CPConfig) ProviderMSPLicenseLapsed(now time.Time) bool { + if c == nil { + return false + } + return providerMSPLicenseLapsed(c.ProviderMSPLicenseExpiresAt, now) +} + +// resolveProviderMSPLicense picks the licence a provider-hosted control plane +// starts on: a current renewed licence first, then a current +// CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence wins because the host file +// is usually the self-issued evaluation, which expires; a paying provider must +// keep starting after it does. When neither is current it still starts, on +// whichever lapsed licence expires later, because refusing to start would take +// down the portal, which is the only place a provider can buy or renew. Both +// candidates must be authentic Pulse licences, and validate still ties the +// chosen one to this control plane's lease signing key. func resolveProviderMSPLicense(hostFile, dataDir string) (*providerMSPLicenseResolution, string, error) { + now := time.Now() + var renewed *providerMSPLicenseResolution renewedPath := ProviderMSPRenewedLicensePath(dataDir) if _, err := os.Stat(renewedPath); err == nil { - renewed, err := resolveProviderMSPPlanFromLicenseFile(renewedPath) - if err == nil { + candidate, err := resolveProviderMSPLicenseFile(renewedPath, true) + if err != nil { + log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE") + } else { + renewed = candidate + } + } + if renewed != nil && !renewed.lapsed(now) { + return renewed, ProviderMSPPlanSourceRenewedLicense, nil + } + host, err := resolveProviderMSPLicenseFile(hostFile, true) + if err != nil { + if renewed != nil { return renewed, ProviderMSPPlanSourceRenewedLicense, nil } - log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE") - } - resolved, err := resolveProviderMSPPlanFromLicenseFile(hostFile) - if err != nil { return nil, "", err } - return resolved, ProviderMSPPlanSourceLicenseFile, nil + if !host.lapsed(now) || renewed == nil || !renewed.ExpiresAt.After(host.ExpiresAt) { + return host, ProviderMSPPlanSourceLicenseFile, nil + } + return renewed, ProviderMSPPlanSourceRenewedLicense, nil } func readProviderMSPLicenseFile(path string) (string, error) { diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index d8100019a..a68f8a5ef 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -991,6 +991,51 @@ func TestLoadConfig_SessionTTLProviderHostedDefault(t *testing.T) { } } +// Refusing to start on a lapsed licence took the provider's portal down at the +// moment the provider needed it to buy. The control plane now starts, reports +// the licence as lapsed, and when both licences have lapsed it starts on the +// one that expired later, so a lapsed paid plan reads as that plan. +func TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence(t *testing.T) { + setProviderHostedMSPEnv(t) + t.Setenv("CP_ENV", "production") + dataDir := t.TempDir() + t.Setenv("CP_DATA_DIR", dataDir) + issuer := newProviderMSPTestIssuer(t) + key := trialSigningEnvPublicKey(t) + hostFile := writeProviderMSPTestFile(t, filepath.Join(t.TempDir(), "eval.jwt"), + issuer.sign(t, "lic_msp_eval", pkglicensing.PlanVersionMSPEval, time.Now().Add(-60*24*time.Hour), key)) + t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", hostFile) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig with only a lapsed evaluation: %v", err) + } + if cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval || cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile { + t.Fatalf("lapsed evaluation resolved plan=%q source=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource) + } + if !cfg.ProviderMSPLicenseLapsed(time.Now()) { + t.Fatal("ProviderMSPLicenseLapsed = false for an evaluation that ended 60 days ago") + } + + writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir), + issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-20*24*time.Hour), key)) + cfg, err = LoadConfig() + if err != nil { + t.Fatalf("LoadConfig with both licences lapsed: %v", err) + } + if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceRenewedLicense || cfg.ProviderMSPPlanVersion != "msp_solo" || !cfg.ProviderMSPLicenseLapsed(time.Now()) { + t.Fatalf("both lapsed resolved plan=%q source=%q lapsed=%v, want the later-expiring paid licence", + cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource, cfg.ProviderMSPLicenseLapsed(time.Now())) + } + + // Inside the 7-day grace the licence is expired but not yet lapsed. + writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir), + issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-2*24*time.Hour), key)) + if cfg, err = LoadConfig(); err != nil || cfg.ProviderMSPLicenseLapsed(time.Now()) { + t.Fatalf("licence 2 days past expiry: err=%v lapsed=%v, want running and not lapsed", err, cfg != nil && cfg.ProviderMSPLicenseLapsed(time.Now())) + } +} + func TestLoadConfig_SessionTTLOverride(t *testing.T) { setProviderHostedMSPEnv(t) t.Setenv("CP_SESSION_TTL", "36h") diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index e6265c00f..2b6d3935c 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "33ddecf9ec76e0a22e0facf6971b8f0825600e56007db5e7f1f4e2cbe1dd2621", + "source_hash": "5307f9f4c72602a934c10d38c5a20b212bddae474198b278648fc751df932267", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 805c55a7b..f6cede0de 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1034,14 +1034,14 @@ } function messageFromPayload(payload, fallback) { if (payload && typeof payload === "object") { - var errorMessage = payload.error; - if (typeof errorMessage === "string" && errorMessage.trim()) { - return errorMessage; - } var message = payload.message; if (typeof message === "string" && message.trim()) { return message; } + var errorMessage = payload.error; + if (typeof errorMessage === "string" && errorMessage.trim()) { + return errorMessage; + } } if (typeof payload === "string" && payload.trim()) { return payload; @@ -1517,7 +1517,7 @@ if (body.error !== "workspace_limit_reached") return ""; var entity = clientLanguage ? "client workspaces" : "workspaces"; var counts = typeof body.current === "number" && typeof body.limit === "number" && body.limit > 0 ? " (" + body.current + " of " + body.limit + " in use)" : ""; - return "Your license limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or upgrade your license to add more."; + return "Your plan limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or move to a bigger plan to add more."; } var createWorkspace = async function(accountID) { var nameEl = getElement("ws-name-" + accountID); @@ -2728,15 +2728,30 @@ var expiresAt = new Date(plan.expires_at).getTime(); return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS; } + function planEnded(plan, now) { + if (plan.lapsed) return true; + if (!plan.expires_at) return false; + var expiresAt = new Date(plan.expires_at).getTime(); + return !isNaN(expiresAt) && now >= expiresAt; + } function renderCurrentPlan(plan, canManage, busy, inUse, now) { var expires = formatDate(plan.expires_at); var title = providerPlanName(plan.plan_version); var description; var meta = [inUse >= 0 ? String(inUse) + " of " + clientWorkspaces(plan.workspace_limit) + " in use" : clientWorkspaces(plan.workspace_limit)]; var cta = ""; - if (plan.evaluation) { + var ended = planEnded(plan, now); + var lostFeatures = plan.lapsed ? " Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added." : ""; + if (plan.evaluation && ended) { + description = "Your evaluation ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Buy a plan to keep your clients monitored and to add more."; + } else if (plan.evaluation) { description = expires ? "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + " until " + expires + ". Buy a plan to keep your clients monitored after that and to add more." : "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + ". Buy a plan to add more."; if (expires) meta.push("Expires " + expires); + } else if (ended) { + description = "Your " + title + " plan ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Renew from Manage billing or buy a plan below."; + if (canManage) { + cta = '"; + } } else { description = paidLicenceInGrace(plan, now) ? "Your subscription has not renewed. Your clients keep this plan until " + expires + ". Open Manage billing to renew or update your payment method." : "Up to " + clientWorkspaces(plan.workspace_limit) + ". Renews automatically while your subscription is active."; if (canManage) { @@ -2774,7 +2789,8 @@ var plan = view.plan; if (!plan) return parts.join(""); parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now)); - if (plan.evaluation) { + var ended = planEnded(plan, now); + if (plan.evaluation || ended) { if (plan.plans_error) { parts.push('"); } else if (plan.purchase_available) { @@ -2783,7 +2799,7 @@ }); var cycle = hasAnnual ? view.cycle : "monthly"; var offers = plan.plans.filter(function(option) { - return option.billing_cycle === cycle && option.workspace_limit > plan.workspace_limit; + return option.billing_cycle === cycle && (ended && !plan.evaluation ? option.workspace_limit >= plan.workspace_limit : option.workspace_limit > plan.workspace_limit); }); parts.push('

Choose a plan

You pay per client workspace, never per monitored system. Every client workspace is full Pulse.

'); parts.push(renderCycleToggle(cycle, hasAnnual)); diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.ts b/internal/cloudcp/portal/frontend/src/account_runtime.ts index 3f8155a54..7d0bf7767 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.ts @@ -188,8 +188,8 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime var counts = typeof body.current === 'number' && typeof body.limit === 'number' && body.limit > 0 ? ' (' + body.current + ' of ' + body.limit + ' in use)' : ''; - return 'Your license limit for ' + entity + ' is reached' + counts + '. Remove a ' + - (clientLanguage ? 'client' : 'workspace') + ' or upgrade your license to add more.'; + return 'Your plan limit for ' + entity + ' is reached' + counts + '. Remove a ' + + (clientLanguage ? 'client' : 'workspace') + ' or move to a bigger plan to add more.'; } var createWorkspace = async function(accountID: string): Promise { diff --git a/internal/cloudcp/portal/frontend/src/api.test.ts b/internal/cloudcp/portal/frontend/src/api.test.ts index e6f350071..8c6ac4e71 100644 --- a/internal/cloudcp/portal/frontend/src/api.test.ts +++ b/internal/cloudcp/portal/frontend/src/api.test.ts @@ -73,6 +73,31 @@ describe('portal api', function() { }); }); + // A code-plus-sentence payload must show the sentence: the provider saw + // "provider_msp_license_lapsed" instead of what to do about it. + it('prefers the human message over the machine error code', async function() { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + headers: new Headers({ 'content-type': 'application/json' }), + json: async function() { + return { error: 'provider_msp_license_lapsed', message: 'Your Pulse MSP plan has ended, so no new clients can be added.' }; + }, + })); + + var api = createPortalAPI({ + getBootstrap: function() { + return bootstrap; + }, + }); + + await expect(api.createWorkspace('acct_1', { display_name: 'Acme' })).rejects.toMatchObject({ + name: 'PortalAPIError', + status: 403, + message: 'Your Pulse MSP plan has ended, so no new clients can be added.', + }); + }); + it('keeps task-specific fallback copy on network failures', async function() { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up'))); diff --git a/internal/cloudcp/portal/frontend/src/api.ts b/internal/cloudcp/portal/frontend/src/api.ts index 2560cb35d..b4a85825d 100644 --- a/internal/cloudcp/portal/frontend/src/api.ts +++ b/internal/cloudcp/portal/frontend/src/api.ts @@ -109,16 +109,19 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI { return null; } + // Control plane errors carry a machine code in "error" and, when there is + // something to tell the person, a sentence in "message". Show the sentence; + // older handlers put their only human text in "error", so fall back to it. function messageFromPayload(payload: unknown, fallback: string): string { if (payload && typeof payload === 'object') { - var errorMessage = (payload as { error?: unknown }).error; - if (typeof errorMessage === 'string' && errorMessage.trim()) { - return errorMessage; - } var message = (payload as { message?: unknown }).message; if (typeof message === 'string' && message.trim()) { return message; } + var errorMessage = (payload as { error?: unknown }).error; + if (typeof errorMessage === 'string' && errorMessage.trim()) { + return errorMessage; + } } if (typeof payload === 'string' && payload.trim()) { return payload; diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts index e9be0d625..7f813e4e7 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.test.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.test.ts @@ -96,6 +96,31 @@ describe('renderProviderPlanHTML', () => { expect(lapsed).not.toContain('Renews automatically'); }); + // The control plane keeps serving the portal on a lapsed licence precisely + // so the provider can buy here; the panel must say so and offer the plans. + it('tells a provider whose evaluation ended what happened and how to buy', () => { + const now = Date.parse('2026-12-10T00:00:00Z'); + const html = renderProviderPlanHTML(view({ + plan: evaluationPlan({ expires_at: '2026-11-22T20:49:54Z', lapsed: true }), + }), true, 2, now); + expect(html).toContain('Your evaluation ended on'); + expect(html).toContain('lost their MSP features, and no new clients can be added'); + expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_solo"'); + expect(html).not.toContain('Your evaluation covers'); + }); + + it('offers the same plan again after a paid plan lapses', () => { + const now = Date.parse('2026-12-10T00:00:00Z'); + const html = renderProviderPlanHTML(view({ + plan: evaluationPlan({ plan_version: 'msp_solo', evaluation: false, workspace_limit: 3, expires_at: '2026-11-20T00:00:00Z', lapsed: true }), + }), true, 3, now); + expect(html).toContain('Your Solo plan ended on'); + expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_solo"'); + expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_starter"'); + expect(html).toContain('data-provider-plan-action="manage-billing"'); + expect(html).not.toContain('Renews automatically'); + }); + it('keeps the evaluation visible when plans cannot be loaded', () => { const html = renderProviderPlanHTML(view({ plan: evaluationPlan({ plans: [], purchase_available: false, plans_error: 'Plans are unavailable right now.' }) }), true); expect(html).toContain('Free evaluation'); diff --git a/internal/cloudcp/portal/frontend/src/provider_plan.ts b/internal/cloudcp/portal/frontend/src/provider_plan.ts index 2e7e05a65..03270a2bc 100644 --- a/internal/cloudcp/portal/frontend/src/provider_plan.ts +++ b/internal/cloudcp/portal/frontend/src/provider_plan.ts @@ -20,6 +20,9 @@ export interface ProviderPlanState { evaluation: boolean; license_id?: string; expires_at?: string; + // Past expiry and grace: client workspaces have lost MSP features and no + // new clients can be added until the provider buys or renews. + lapsed?: boolean; workspace_limit: number; purchase_available: boolean; plans: ProviderPlanOption[]; @@ -80,17 +83,38 @@ function paidLicenceInGrace(plan: ProviderPlanState, now: number): boolean { return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS; } +// The licence has run out: the evaluation or paid period is over. The control +// plane keeps serving this portal so the provider can buy or renew here. +function planEnded(plan: ProviderPlanState, now: number): boolean { + if (plan.lapsed) return true; + if (!plan.expires_at) return false; + var expiresAt = new Date(plan.expires_at).getTime(); + return !isNaN(expiresAt) && now >= expiresAt; +} + function renderCurrentPlan(plan: ProviderPlanState, canManage: boolean, busy: string, inUse: number, now: number): string { var expires = formatDate(plan.expires_at); var title = providerPlanName(plan.plan_version); var description: string; var meta: string[] = [inUse >= 0 ? String(inUse) + ' of ' + clientWorkspaces(plan.workspace_limit) + ' in use' : clientWorkspaces(plan.workspace_limit)]; var cta = ''; - if (plan.evaluation) { + var ended = planEnded(plan, now); + var lostFeatures = plan.lapsed + ? ' Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added.' + : ''; + if (plan.evaluation && ended) { + description = 'Your evaluation ended' + (expires ? ' on ' + expires : '') + '.' + lostFeatures + ' Buy a plan to keep your clients monitored and to add more.'; + } else if (plan.evaluation) { description = expires ? 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + ' until ' + expires + '. Buy a plan to keep your clients monitored after that and to add more.' : 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + '. Buy a plan to add more.'; if (expires) meta.push('Expires ' + expires); + } else if (ended) { + description = 'Your ' + title + ' plan ended' + (expires ? ' on ' + expires : '') + '.' + lostFeatures + ' Renew from Manage billing or buy a plan below.'; + if (canManage) { + cta = ''; + } } else { // The renewal date lives in Manage billing; the licence date only // matters, and only shows, once the subscription has stopped renewing. @@ -162,14 +186,17 @@ export function renderProviderPlanHTML(view: ProviderPlanView, canManage: boolea if (!plan) return parts.join(''); parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now)); - if (plan.evaluation) { + var ended = planEnded(plan, now); + if (plan.evaluation || ended) { if (plan.plans_error) { parts.push(''); } else if (plan.purchase_available) { var hasAnnual = plan.plans.some(function(option) { return option.billing_cycle === 'annual'; }); var cycle = hasAnnual ? view.cycle : 'monthly'; + // After a paid plan ends, buying the same plan again is a valid choice. var offers = plan.plans.filter(function(option) { - return option.billing_cycle === cycle && option.workspace_limit > plan!.workspace_limit; + return option.billing_cycle === cycle && + (ended && !plan!.evaluation ? option.workspace_limit >= plan!.workspace_limit : option.workspace_limit > plan!.workspace_limit); }); parts.push('

Choose a plan

You pay per client workspace, never per monitored system. Every client workspace is full Pulse.

'); parts.push(renderCycleToggle(cycle, hasAnnual)); diff --git a/internal/cloudcp/provider_msp_license_refresh.go b/internal/cloudcp/provider_msp_license_refresh.go index e5f46726c..9d9933020 100644 --- a/internal/cloudcp/provider_msp_license_refresh.go +++ b/internal/cloudcp/provider_msp_license_refresh.go @@ -389,6 +389,7 @@ type ProviderMSPPlanState struct { Evaluation bool `json:"evaluation"` LicenseID string `json:"license_id,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` + Lapsed bool `json:"lapsed"` WorkspaceLimit int `json:"workspace_limit"` PurchaseAvailable bool `json:"purchase_available"` Plans []ProviderMSPPurchasablePlan `json:"plans"` @@ -409,6 +410,7 @@ func providerMSPPlanState(ctx context.Context, cfg *CPConfig, refresher *Provide if !cfg.ProviderMSPLicenseExpiresAt.IsZero() { state.ExpiresAt = cfg.ProviderMSPLicenseExpiresAt.Format(time.RFC3339) } + state.Lapsed = cfg.ProviderMSPLicenseLapsed(time.Now()) if refresher == nil || state.LicenseID == "" { return state } diff --git a/internal/cloudcp/provider_msp_license_refresh_test.go b/internal/cloudcp/provider_msp_license_refresh_test.go index 41c3ee7db..a73d4b0d6 100644 --- a/internal/cloudcp/provider_msp_license_refresh_test.go +++ b/internal/cloudcp/provider_msp_license_refresh_test.go @@ -244,6 +244,26 @@ func TestProviderMSPLicenseRefreshAppliesShorterPaidPeriod(t *testing.T) { } } +// Startup tolerates a lapsed licence so the portal stays up, but the refresher +// must never adopt one the licence server returns. +func TestProviderMSPLicenseRefreshRefusesALapsedLicence(t *testing.T) { + issuer := newProviderMSPTestIssuer(t) + fake := &fakeProviderMSPLicenseServer{t: t, currentStatus: http.StatusOK} + server := httptest.NewServer(fake) + defer server.Close() + cfg := newProviderMSPRefreshTestConfig(t, server.URL) + fake.currentLicense = issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-30*24*time.Hour), trialSigningEnvPublicKey(t)) + refresher := NewProviderMSPLicenseRefresher(cfg) + refresher.SetRestart(func() { t.Fatal("a lapsed licence must not restart the control plane") }) + + if _, err := refresher.Refresh(context.Background()); err == nil || !strings.Contains(err.Error(), "unusable") { + t.Fatalf("Refresh with a lapsed licence err = %v, want refusal", err) + } + if _, err := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); !os.IsNotExist(err) { + t.Fatalf("lapsed licence was installed: %v", err) + } +} + func TestProviderMSPPortalRoutesRelayToTheLicenceServer(t *testing.T) { fake := &fakeProviderMSPLicenseServer{ t: t, diff --git a/internal/cloudcp/routes.go b/internal/cloudcp/routes.go index 958acaaed..69322b89d 100644 --- a/internal/cloudcp/routes.go +++ b/internal/cloudcp/routes.go @@ -250,6 +250,7 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) { if deps.Config.IsMSPControlPlane() { workspaceLimitPolicy.ProviderHostedMSP = true workspaceLimitPolicy.ProviderMSPPlanVersion = providerMSPPlanVersion(deps.Config) + workspaceLimitPolicy.ProviderMSPLicenseLapsed = func() bool { return deps.Config.ProviderMSPLicenseLapsed(time.Now()) } } createTenant := account.HandleCreateTenantWithWorkspaceLimitPolicy(deps.Registry, provisioner, workspaceLimitPolicy) updateTenant := account.HandleUpdateTenant(deps.Registry) diff --git a/pkg/licensing/cloud_paid_guardrails_test.go b/pkg/licensing/cloud_paid_guardrails_test.go index 90093bec6..844bb6d65 100644 --- a/pkg/licensing/cloud_paid_guardrails_test.go +++ b/pkg/licensing/cloud_paid_guardrails_test.go @@ -1,6 +1,14 @@ package licensing -import "testing" +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "testing" + "time" +) func TestNormalizeBillingStatePreservesMissingPlanVersionAndScrubsRetiredMonitoringLimit(t *testing.T) { state := &BillingState{ @@ -93,3 +101,43 @@ func TestCloudClaimsMissingPlanVersionDoesNotReintroduceMonitoringLimit(t *testi t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", claims.EffectiveLimits()) } } + +// A provider control plane must keep starting on a lapsed licence so its +// portal can sell the renewal, but the licence must still be authentic, and +// client runtimes must still refuse to take MSP capabilities from it. +func TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence(t *testing.T) { + setupTestPublicKey(t) + providerPub, providerPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate provider key pair: %v", err) + } + lapsed := mintTestProviderMSPLicense(t, TierMSP, providerPub, -30*24*time.Hour) + + if _, err := ValidateLicense(lapsed); !errors.Is(err, ErrExpiredLicense) { + t.Fatalf("ValidateLicense(lapsed) error = %v, want ErrExpiredLicense", err) + } + license, err := ValidateLicenseAllowingLapse(lapsed) + if err != nil || license == nil { + t.Fatalf("ValidateLicenseAllowingLapse(lapsed) = %v, %v; want the licence", license, err) + } + if !license.IsExpired() || license.GracePeriodEnd == nil || !license.GracePeriodEnd.Before(time.Now()) { + t.Fatalf("lapsed licence expired=%v graceEnd=%v; want expired with a grace end in the past", license.IsExpired(), license.GracePeriodEnd) + } + + // Authenticity is not relaxed: a licence not signed by the Pulse root is + // still refused. + claims := Claims{LicenseID: "lic_forged", Email: "attacker@example.com", Tier: TierMSP, IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(-30 * 24 * time.Hour).Unix(), PlanVersion: "msp_starter", + EntitlementSigningPublicKey: base64.StdEncoding.EncodeToString(providerPub)} + payload, _ := json.Marshal(claims) + if _, err := ValidateLicenseAllowingLapse(signTestJWT(t, payload, providerPriv)); err == nil { + t.Fatal("ValidateLicenseAllowingLapse accepted a licence not signed by the Pulse root") + } + + // Enforcement stays with the runtime: a lease chained to the lapsed + // licence does not verify. + token := signTestProviderLease(t, providerPriv, lapsed, []string{FeatureWhiteLabel, FeatureMultiTenant}) + if _, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()); err == nil { + t.Fatal("a lease chained to a lapsed provider licence must not verify") + } +} diff --git a/pkg/licensing/service.go b/pkg/licensing/service.go index 1a8b70ecb..2c7710674 100644 --- a/pkg/licensing/service.go +++ b/pkg/licensing/service.go @@ -935,6 +935,21 @@ func cloneClaims(in Claims) Claims { // ValidateLicense validates a license key and returns the license if valid. func ValidateLicense(licenseKey string) (*License, error) { + return validateLicense(licenseKey, false) +} + +// ValidateLicenseAllowingLapse applies every ValidateLicense check (format, +// Pulse signature, required claims) but returns a licence past its expiry and +// grace period instead of rejecting it, with GracePeriodEnd set. The caller +// owns enforcement. A provider-hosted MSP control plane uses it to keep +// starting on a lapsed licence so its portal can sell the renewal; the client +// runtimes it serves still verify the licence with ValidateLicense and drop +// MSP capabilities once it lapses. +func ValidateLicenseAllowingLapse(licenseKey string) (*License, error) { + return validateLicense(licenseKey, true) +} + +func validateLicense(licenseKey string, allowLapsed bool) (*License, error) { // Trim whitespace licenseKey = strings.TrimSpace(licenseKey) if licenseKey == "" { @@ -1028,6 +1043,8 @@ func ValidateLicense(licenseKey string) (*License, error) { // Within grace period - allow activation but mark as in grace period license.GracePeriodEnd = &gracePeriodEnd // License is still valid during grace period + } else if allowLapsed { + license.GracePeriodEnd = &gracePeriodEnd } else { // Past grace period - reject return nil, fmt.Errorf("%w: expired on %s (grace period ended %s)",