diff --git a/cmd/pulse-control-plane/provider_msp_preflight.go b/cmd/pulse-control-plane/provider_msp_preflight.go index 36473588ea..967f41c4ef 100644 --- a/cmd/pulse-control-plane/provider_msp_preflight.go +++ b/cmd/pulse-control-plane/provider_msp_preflight.go @@ -106,7 +106,7 @@ func runProviderMSPPreflightWithDependencies(ctx context.Context, cfg *cloudcp.C } else { report.WorkspaceLimit = workspaceLimit } - if !opts.AllowEnvPlan && strings.TrimSpace(cfg.ProviderMSPPlanSource) != cloudcp.ProviderMSPPlanSourceLicenseFile { + if !opts.AllowEnvPlan && !cloudcp.ProviderMSPPlanSourceIsSignedLicense(cfg.ProviderMSPPlanSource) { addFailure("provider MSP preflight requires %s plan source; rerun with --allow-env-plan only for local development", cloudcp.ProviderMSPPlanSourceLicenseFile) } diff --git a/cmd/pulse-control-plane/provider_msp_preflight_test.go b/cmd/pulse-control-plane/provider_msp_preflight_test.go index b4848c1899..a8be6e8fed 100644 --- a/cmd/pulse-control-plane/provider_msp_preflight_test.go +++ b/cmd/pulse-control-plane/provider_msp_preflight_test.go @@ -112,6 +112,32 @@ func TestProviderMSPPreflightPassesWithLicenseDockerAndStorage(t *testing.T) { } } +// A paying provider runs on the licence its control plane renewed into the +// data directory; preflight must treat that signed source like the host +// licence file rather than refuse it as an environment fallback. +func TestProviderMSPPreflightAcceptsRenewedLicenseSource(t *testing.T) { + docker := &fakeProviderMSPPreflightDocker{ + report: &cpDocker.RuntimePrerequisiteReport{ + OK: true, DockerReachable: true, NetworkName: "pulse-provider-msp", NetworkOK: true, + NetworkID: "network-test", ImageRef: "pulse:test", ImageID: "sha256:test", ImageAvailable: true, + }, + } + storage := &cloudcp.StorageGuardrailReport{Enabled: true, OK: true, BuildCache: cloudcp.StorageBuildCacheReport{OK: true, MaxBytes: 1024}} + + report, err := runProviderMSPPreflightWithDependencies( + context.Background(), + testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceRenewedLicense), + providerMSPPreflightOptions{}, + fakeProviderMSPPreflightDependencies(docker, storage), + ) + if err != nil { + t.Fatalf("runProviderMSPPreflightWithDependencies: %v", err) + } + if !report.OK { + t.Fatalf("preflight refused a renewed licence source: %v", report.Failures) + } +} + func TestProviderMSPPreflightAcceptsPulseHostedMSPMode(t *testing.T) { docker := &fakeProviderMSPPreflightDocker{ report: &cpDocker.RuntimePrerequisiteReport{ diff --git a/cmd/pulse-control-plane/provider_msp_proof.go b/cmd/pulse-control-plane/provider_msp_proof.go index 166105930b..8c4982645d 100644 --- a/cmd/pulse-control-plane/provider_msp_proof.go +++ b/cmd/pulse-control-plane/provider_msp_proof.go @@ -247,7 +247,7 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts if err != nil { return nil, err } - if !opts.AllowEnvPlan && strings.TrimSpace(rt.cfg.ProviderMSPPlanSource) != cloudcp.ProviderMSPPlanSourceLicenseFile { + if !opts.AllowEnvPlan && !cloudcp.ProviderMSPPlanSourceIsSignedLicense(rt.cfg.ProviderMSPPlanSource) { return nil, fmt.Errorf("provider MSP proof requires %s plan source; rerun with --allow-env-plan only for local development", cloudcp.ProviderMSPPlanSourceLicenseFile) } diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 1c13c62786..654d41bf63 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3431,6 +3431,36 @@ Pulse-service-backed capabilities. `providerChained` retains its narrower meaning: a Pulse-signed licence is available to embed so release builds can verify the lease. +### Provider-hosted MSP platforms buy and renew their own licence + +A provider-hosted control plane now buys and renews its licence through the +licence server's self-serve provider MSP path (pulse-pro +`license-server/provider_msp_purchase.go`), with no key copy and nobody at +Pulse in the loop. Portal routes under `/api/accounts/{account_id}/provider-msp/` +serve plan state to any member. Checkout, the Stripe billing portal and an +immediate licence refresh are restricted to owners and admins. The purchasable +plan list comes from the licence server's `GET /v1/provider-msp/plans`, so the +portal offers only what the catalogue sells, at Stripe's price, and drops any +plan without a known workspace limit. `ProviderMSPLicenseRefresher` signs +every request that must come from this platform with its lease signing key, +validates the returned licence the way startup does, requires it to bind this +platform's key, and installs it at `ProviderMSPRenewedLicensePath`. +`LoadConfig` prefers that renewed licence while it validates (plan source +`renewed_license`), so a paying provider keeps starting after the self-issued +evaluation on the host expires. A changed licence restarts the control plane +through the graceful shutdown path, because the plan version is read once at +load. `msp_solo` (3 client workspaces) is the first paid step above the +2-workspace evaluation. Regression coverage: +`TestLoadConfig_ProviderHostedMSPPrefersRenewedLicense` in +`internal/cloudcp/config_test.go`, +`TestProviderMSPLicenseRefreshInstallsThePaidLicenceAndRestarts` and +`TestProviderMSPPortalRoutesRelayToTheLicenceServer` in +`internal/cloudcp/provider_msp_license_refresh_test.go`, +`TestRegisterRoutes_ProviderMSPPurchaseRoutesRequireOwnerOrAdmin` in +`internal/cloudcp/routes_auth_test.go`, and +`TestProviderMSPWorkspaceLadderRisesFromTheEvaluation` in +`pkg/licensing/features_test.go`. + ### Runtime display load remains outside commercial bootstrap ownership `frontend-modern/src/useAppRuntimeState.ts` now loads presentation defaults diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index eaa6eb2501..e008c4d69f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2416,6 +2416,19 @@ artifact-selection behaviour. ## Current State +### Provider MSP operations accept a renewed licence + +`provider-msp preflight`, `proof`, `recover` and `backup` treat a licence +the control plane renewed into its data directory (plan source +`renewed_license`) the same as the host `CP_PROVIDER_MSP_LICENSE_FILE`, +through `ProviderMSPPlanSourceIsSignedLicense`. Only the development +environment fallback is still refused. Without this, the first paid renewal +would have made every day-2 command refuse to run on the platform it was paid +for. Backups keep including the host licence file, and a restored platform +fetches its renewed licence again on the next refresh. Regression coverage: +`TestProviderMSPPreflightAcceptsRenewedLicenseSource` in +`cmd/pulse-control-plane/provider_msp_preflight_test.go`. + ### Reviewed frontend dependency security floors The 2026-09-21 npm-minor-patch group raises the reviewed floors of the diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index b44bfc7965..19be82762b 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -15,6 +15,7 @@ import ( "github.com/joho/godotenv" runtimeconfig "github.com/rcourtman/pulse-go-rewrite/internal/config" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" + "github.com/rs/zerolog/log" ) // ControlPlaneMode selects the business/runtime shape exposed by cloudcp. @@ -31,6 +32,10 @@ const ( defaultProviderHostedMSPPlanVersion = pkglicensing.PlanVersionMSPEval ProviderMSPPlanSourceLicenseFile = "license_file" ProviderMSPPlanSourceEnvFallback = "environment_fallback" + // ProviderMSPPlanSourceRenewedLicense is a licence the control plane + // fetched from the licence server for its paid subscription and kept in + // its data directory (ProviderMSPRenewedLicensePath). + ProviderMSPPlanSourceRenewedLicense = "renewed_license" maxProviderMSPLicenseFileBytes = 64 * 1024 // cpauthDefaultSessionTTL mirrors cpauth.SessionTTL for Pulse-hosted @@ -95,6 +100,7 @@ type CPConfig struct { ProviderMSPLicenseEmail string // Validated provider MSP license holder ProviderMSPLicenseKey string // Raw validated provider MSP license (chained into tenant leases) ProviderMSPLeaseSigningPublicKey string // entitlement_signing_public_key claim from the provider MSP license + ProviderMSPLicenseExpiresAt time.Time LicenseServerURL string LicenseAdminToken string TrialActivationPrivateKey string @@ -215,13 +221,15 @@ func LoadConfig() (*CPConfig, error) { providerMSPLicenseEmail := "" providerMSPLicenseKey := "" providerMSPLeaseSigningPublicKey := "" + providerMSPLicenseExpiresAt := time.Time{} if isMSPControlPlaneMode(controlPlaneMode) && providerMSPLicenseFile != "" { - resolved, err := resolveProviderMSPPlanFromLicenseFile(providerMSPLicenseFile) + resolved, source, err := resolveProviderMSPLicense(providerMSPLicenseFile, dataDir) if err != nil { return nil, fmt.Errorf("resolve provider MSP license: %w", err) } providerMSPPlanVersion = resolved.PlanVersion - providerMSPPlanSource = ProviderMSPPlanSourceLicenseFile + providerMSPPlanSource = source + providerMSPLicenseExpiresAt = resolved.ExpiresAt providerMSPLicenseID = resolved.LicenseID providerMSPLicenseEmail = resolved.LicenseEmail providerMSPLicenseKey = resolved.LicenseKey @@ -280,6 +288,7 @@ func LoadConfig() (*CPConfig, error) { ProviderMSPLicenseFile: providerMSPLicenseFile, ProviderMSPLicenseKey: providerMSPLicenseKey, ProviderMSPLeaseSigningPublicKey: providerMSPLeaseSigningPublicKey, + ProviderMSPLicenseExpiresAt: providerMSPLicenseExpiresAt, ProviderMSPLicenseID: providerMSPLicenseID, ProviderMSPLicenseEmail: providerMSPLicenseEmail, LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"), @@ -614,6 +623,7 @@ type providerMSPLicenseResolution struct { LicenseEmail string LicenseKey string // raw signed license, chained into tenant entitlement leases LeaseSigningPublicKey string // entitlement_signing_public_key claim, empty when the license predates lease chaining + ExpiresAt time.Time } func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) { @@ -646,13 +656,59 @@ func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseReso if _, known := pkglicensing.WorkspaceLimitForPlan(plan); !known { return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must have a known workspace limit, got %q", plan) } - return &providerMSPLicenseResolution{ + resolution := &providerMSPLicenseResolution{ PlanVersion: plan, LicenseID: strings.TrimSpace(license.Claims.LicenseID), LicenseEmail: strings.ToLower(strings.TrimSpace(license.Claims.Email)), LicenseKey: licenseKey, LeaseSigningPublicKey: strings.TrimSpace(license.Claims.EntitlementSigningPublicKey), - }, nil + } + if license.Claims.ExpiresAt > 0 { + resolution.ExpiresAt = time.Unix(license.Claims.ExpiresAt, 0).UTC() + } + return resolution, nil +} + +// ProviderMSPPlanSourceIsSignedLicense reports whether the plan came from a +// Pulse-signed licence, either the host licence file or a renewed licence, +// rather than the development-only environment fallback. +func ProviderMSPPlanSourceIsSignedLicense(source string) bool { + switch strings.TrimSpace(source) { + case ProviderMSPPlanSourceLicenseFile, ProviderMSPPlanSourceRenewedLicense: + return true + default: + return false + } +} + +// ProviderMSPRenewedLicensePath is where a provider-hosted control plane +// keeps the licence it fetched for its paid subscription. It lives in the +// data directory, which the control plane can write and backups carry, +// because CP_PROVIDER_MSP_LICENSE_FILE is a read-only secret mount. +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. +func resolveProviderMSPLicense(hostFile, dataDir string) (*providerMSPLicenseResolution, string, error) { + renewedPath := ProviderMSPRenewedLicensePath(dataDir) + if _, err := os.Stat(renewedPath); err == nil { + renewed, err := resolveProviderMSPPlanFromLicenseFile(renewedPath) + if err == 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 } func readProviderMSPLicenseFile(path string) (string, error) { diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index ba05053a50..d8100019ad 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "os" + "path/filepath" "strings" "testing" "time" @@ -1011,3 +1012,50 @@ func TestLoadConfig_SessionTTLInvalid(t *testing.T) { t.Fatal("expected error for invalid CP_SESSION_TTL") } } + +// A paying provider must keep starting after the self-issued evaluation on +// the host expires: a renewed licence that validates wins, and an unusable +// one falls back to the host file. +func TestLoadConfig_ProviderHostedMSPPrefersRenewedLicense(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) + renewedExpiry := time.Now().Add(40 * 24 * time.Hour).Truncate(time.Second) + writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir), + issuer.sign(t, "lic_msp_paid", "msp_solo", renewedExpiry, key)) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig with an expired evaluation and a valid renewed licence: %v", err) + } + if cfg.ProviderMSPPlanVersion != "msp_solo" || cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceRenewedLicense || cfg.ProviderMSPLicenseID != "lic_msp_paid" { + t.Fatalf("resolved plan=%q source=%q license=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource, cfg.ProviderMSPLicenseID) + } + if !cfg.ProviderMSPLicenseExpiresAt.Equal(renewedExpiry) { + t.Fatalf("ProviderMSPLicenseExpiresAt = %v, want %v", cfg.ProviderMSPLicenseExpiresAt, renewedExpiry) + } + if limit, _ := pkglicensing.WorkspaceLimitForPlan(cfg.ProviderMSPPlanVersion); limit != 3 { + t.Fatalf("msp_solo workspace limit = %d, want 3", limit) + } + + // An expired renewed licence falls back to a valid host licence. + writeProviderMSPTestFile(t, hostFile, issuer.sign(t, "lic_msp_eval", pkglicensing.PlanVersionMSPEval, time.Now().Add(30*24*time.Hour), key)) + writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir), issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-60*24*time.Hour), key)) + cfg, err = LoadConfig() + if err != nil { + t.Fatalf("LoadConfig with an expired renewed licence: %v", err) + } + if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile || cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval { + t.Fatalf("fallback plan=%q source=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource) + } + if !ProviderMSPPlanSourceIsSignedLicense(ProviderMSPPlanSourceRenewedLicense) || ProviderMSPPlanSourceIsSignedLicense(ProviderMSPPlanSourceEnvFallback) { + t.Fatal("ProviderMSPPlanSourceIsSignedLicense must accept both signed sources and nothing else") + } +} diff --git a/internal/cloudcp/provider_msp_backup.go b/internal/cloudcp/provider_msp_backup.go index 34a0ec38e9..7593c324ec 100644 --- a/internal/cloudcp/provider_msp_backup.go +++ b/internal/cloudcp/provider_msp_backup.go @@ -801,7 +801,7 @@ func providerMSPBackupRequiresRuntimeTenantDir(state registry.TenantState) bool } func resolveProviderMSPBackupLicensePath(cfg *CPConfig) (string, bool, error) { - if providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource) != ProviderMSPPlanSourceLicenseFile { + if !ProviderMSPPlanSourceIsSignedLicense(providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource)) { return "", false, nil } licensePath := strings.TrimSpace(cfg.ProviderMSPLicenseFile) diff --git a/internal/cloudcp/provider_msp_license_refresh.go b/internal/cloudcp/provider_msp_license_refresh.go new file mode 100644 index 0000000000..e467fb2623 --- /dev/null +++ b/internal/cloudcp/provider_msp_license_refresh.go @@ -0,0 +1,531 @@ +package cloudcp + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/portal" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" + "github.com/rs/zerolog/log" +) + +// A provider-hosted control plane buys and renews its own licence. The +// licence server knows it only by the Ed25519 lease signing key setup.sh +// generated on the host, so every call that must come from this platform is +// signed with that key, and the licence the server returns binds the same +// key. See pulse-pro license-server/provider_msp_purchase.go for the server +// half. + +const ( + providerMSPChallengePrefix = "pulse-provider-msp-v1" + providerMSPLicenseRefreshInterval = 6 * time.Hour + providerMSPLicenseRefreshInitialDelay = 2 * time.Minute + providerMSPLicenseRestartDelay = 2 * time.Second + providerMSPPlansCacheTTL = 5 * time.Minute + providerMSPLicenseHTTPTimeout = 20 * time.Second + maxProviderMSPLicenseResponseBytes = 256 * 1024 +) + +// ErrProviderMSPAlreadySubscribed is returned when checkout is refused because +// this platform already pays; the billing portal is the way to change plan. +var ErrProviderMSPAlreadySubscribed = errors.New("provider MSP platform already has a paid subscription") + +// ErrProviderMSPNoSubscription is returned when the licence server has no paid +// subscription bound to this platform's key. +var ErrProviderMSPNoSubscription = errors.New("no paid provider MSP subscription") + +// ProviderMSPPurchasablePlan is one plan the licence server sells now. +type ProviderMSPPurchasablePlan struct { + PlanVersion string `json:"plan_version"` + BillingCycle string `json:"billing_cycle"` + UnitAmount int64 `json:"unit_amount"` + Currency string `json:"currency"` + WorkspaceLimit int `json:"workspace_limit"` +} + +// ProviderMSPLicenseRefreshResult reports one refresh. +type ProviderMSPLicenseRefreshResult struct { + Status string `json:"status"` // "active" or "no_paid_subscription" + Changed bool `json:"changed"` + RestartScheduled bool `json:"restart_scheduled"` + LicenseID string `json:"license_id,omitempty"` + PlanVersion string `json:"plan_version,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + PaidThrough string `json:"paid_through,omitempty"` +} + +// ProviderMSPLicenseRefresher fetches, validates and installs the licence for +// this platform's paid subscription, and relays checkout and billing-portal +// requests to the licence server. +type ProviderMSPLicenseRefresher struct { + cfg *CPConfig + client *http.Client + now func() time.Time + + mu sync.Mutex // serialises refreshes + restart func() + + plansMu sync.Mutex + plans []ProviderMSPPurchasablePlan + plansCachedAt time.Time +} + +// NewProviderMSPLicenseRefresher returns nil unless this control plane is a +// provider-hosted MSP platform that can sign requests to a licence server. +func NewProviderMSPLicenseRefresher(cfg *CPConfig) *ProviderMSPLicenseRefresher { + if cfg == nil || !cfg.IsProviderHostedMSP() { + return nil + } + if strings.TrimSpace(cfg.TrialActivationPrivateKey) == "" || strings.TrimSpace(cfg.LicenseServerURL) == "" { + return nil + } + return &ProviderMSPLicenseRefresher{ + cfg: cfg, + client: &http.Client{Timeout: providerMSPLicenseHTTPTimeout}, + now: time.Now, + } +} + +// SetRestart installs the hook that restarts the control plane so a changed +// licence takes effect. The plan version is read at load by every workspace +// limit, backup and status path, so a restart is the one way to apply it +// everywhere at once; client workspaces keep running meanwhile. +func (r *ProviderMSPLicenseRefresher) SetRestart(restart func()) { + if r == nil { + return + } + r.mu.Lock() + r.restart = restart + r.mu.Unlock() +} + +type providerMSPSignedRequest struct { + EntitlementSigningPublicKey string `json:"entitlement_signing_public_key"` + Timestamp int64 `json:"timestamp"` + Signature string `json:"signature"` + ReturnURL string `json:"return_url,omitempty"` +} + +func (r *ProviderMSPLicenseRefresher) signedRequest(action, returnURL string) (providerMSPSignedRequest, error) { + privateKey, err := pkglicensing.DecodeEd25519PrivateKey(strings.TrimSpace(r.cfg.TrialActivationPrivateKey)) + if err != nil { + return providerMSPSignedRequest{}, fmt.Errorf("decode lease signing key: %w", err) + } + publicKey, ok := privateKey.Public().(ed25519.PublicKey) + if !ok { + return providerMSPSignedRequest{}, fmt.Errorf("derive lease signing public key") + } + encoded := base64.StdEncoding.EncodeToString(publicKey) + timestamp := r.now().Unix() + message := providerMSPChallengePrefix + "\n" + action + "\n" + encoded + "\n" + strconv.FormatInt(timestamp, 10) + return providerMSPSignedRequest{ + EntitlementSigningPublicKey: encoded, + Timestamp: timestamp, + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(message))), + ReturnURL: returnURL, + }, nil +} + +func (r *ProviderMSPLicenseRefresher) call(ctx context.Context, method, path string, body any, out any) (int, error) { + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return 0, err + } + reader = bytes.NewReader(payload) + } + endpoint := strings.TrimRight(strings.TrimSpace(r.cfg.LicenseServerURL), "/") + path + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return 0, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := r.client.Do(req) + if err != nil { + return 0, fmt.Errorf("licence server unreachable: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderMSPLicenseResponseBytes)) + if err != nil { + return resp.StatusCode, err + } + if out != nil && len(raw) > 0 && resp.StatusCode < 500 { + if err := json.Unmarshal(raw, out); err != nil && resp.StatusCode == http.StatusOK { + return resp.StatusCode, fmt.Errorf("decode licence server response: %w", err) + } + } + return resp.StatusCode, nil +} + +// Refresh asks the licence server for the licence its paid subscription +// entitles, installs it when it differs from the running one, and schedules +// a restart so it takes effect. A platform without a paid subscription keeps +// the licence it already has. +func (r *ProviderMSPLicenseRefresher) Refresh(ctx context.Context) (ProviderMSPLicenseRefreshResult, error) { + r.mu.Lock() + defer r.mu.Unlock() + + request, err := r.signedRequest("license-current", "") + if err != nil { + return ProviderMSPLicenseRefreshResult{}, err + } + var resp struct { + Status string `json:"status"` + LicenseID string `json:"license_id"` + License string `json:"license"` + PlanVersion string `json:"plan_version"` + PaidThrough string `json:"paid_through"` + ExpiresAt string `json:"expires_at"` + } + status, err := r.call(ctx, http.MethodPost, "/v1/provider-msp/license/current", request, &resp) + if err != nil { + return ProviderMSPLicenseRefreshResult{}, err + } + switch status { + case http.StatusOK: + case http.StatusNotFound: + return ProviderMSPLicenseRefreshResult{Status: "no_paid_subscription"}, nil + default: + return ProviderMSPLicenseRefreshResult{}, fmt.Errorf("licence server refused the refresh (HTTP %d)", status) + } + + renewedPath := ProviderMSPRenewedLicensePath(r.cfg.DataDir) + if err := os.MkdirAll(filepath.Dir(renewedPath), 0o700); err != nil { + return ProviderMSPLicenseRefreshResult{}, err + } + staged, err := os.CreateTemp(filepath.Dir(renewedPath), ".provider-msp-license-*.jwt") + if err != nil { + return ProviderMSPLicenseRefreshResult{}, err + } + stagedPath := staged.Name() + defer os.Remove(stagedPath) + if _, err := staged.WriteString(strings.TrimSpace(resp.License) + "\n"); err != nil { + staged.Close() + return ProviderMSPLicenseRefreshResult{}, err + } + if err := staged.Chmod(0o600); err != nil { + staged.Close() + return ProviderMSPLicenseRefreshResult{}, err + } + if err := staged.Close(); err != nil { + return ProviderMSPLicenseRefreshResult{}, err + } + + // The same validation a startup applies: Pulse signature, MSP tier, a + // plan with a known workspace limit. Then the key must be this + // platform's, or validate would refuse to start on it. + renewed, err := resolveProviderMSPPlanFromLicenseFile(stagedPath) + if err != nil { + return ProviderMSPLicenseRefreshResult{}, fmt.Errorf("licence server returned an unusable licence: %w", err) + } + if strings.TrimSpace(r.cfg.TrialActivationPublicKey) == "" || renewed.LeaseSigningPublicKey != r.cfg.TrialActivationPublicKey { + return ProviderMSPLicenseRefreshResult{}, fmt.Errorf("licence server returned a licence bound to another platform's key") + } + + result := ProviderMSPLicenseRefreshResult{ + Status: "active", + LicenseID: renewed.LicenseID, + PlanVersion: renewed.PlanVersion, + PaidThrough: resp.PaidThrough, + } + if !renewed.ExpiresAt.IsZero() { + result.ExpiresAt = renewed.ExpiresAt.Format(time.RFC3339) + } + result.Changed = renewed.LicenseID != r.cfg.ProviderMSPLicenseID || + renewed.PlanVersion != r.cfg.ProviderMSPPlanVersion || + renewed.ExpiresAt.After(r.cfg.ProviderMSPLicenseExpiresAt.Add(time.Hour)) + if !result.Changed { + return result, nil + } + if err := os.Rename(stagedPath, renewedPath); err != nil { + return ProviderMSPLicenseRefreshResult{}, err + } + log.Info(). + Str("license_id", renewed.LicenseID). + Str("plan_version", renewed.PlanVersion). + Str("expires_at", result.ExpiresAt). + Msg("Installed renewed provider MSP license") + if r.restart != nil { + restart := r.restart + result.RestartScheduled = true + time.AfterFunc(providerMSPLicenseRestartDelay, func() { + log.Info().Msg("Restarting control plane to apply the renewed provider MSP license") + restart() + }) + } + return result, nil +} + +// Run refreshes shortly after start and then periodically. Failures are +// logged and retried on the next tick; the running licence stays in force. +func (r *ProviderMSPLicenseRefresher) Run(ctx context.Context) { + if r == nil { + return + } + timer := time.NewTimer(providerMSPLicenseRefreshInitialDelay) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + if result, err := r.Refresh(ctx); err != nil { + log.Warn().Err(err).Msg("Provider MSP license refresh failed; keeping the current license") + } else if result.Changed { + log.Info().Str("plan_version", result.PlanVersion).Msg("Provider MSP license refresh installed a new license") + } + timer.Reset(providerMSPLicenseRefreshInterval) + } + } +} + +// Plans returns what the licence server sells now, with each plan's client +// workspace cap, cached briefly. +func (r *ProviderMSPLicenseRefresher) Plans(ctx context.Context) ([]ProviderMSPPurchasablePlan, error) { + r.plansMu.Lock() + defer r.plansMu.Unlock() + if r.plans != nil && r.now().Sub(r.plansCachedAt) < providerMSPPlansCacheTTL { + return r.plans, nil + } + var resp struct { + Plans []ProviderMSPPurchasablePlan `json:"plans"` + } + status, err := r.call(ctx, http.MethodGet, "/v1/provider-msp/plans", nil, &resp) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("licence server plans unavailable (HTTP %d)", status) + } + plans := make([]ProviderMSPPurchasablePlan, 0, len(resp.Plans)) + for _, plan := range resp.Plans { + limit, known := pkglicensing.WorkspaceLimitForPlan(plan.PlanVersion) + if !known { + // A plan this control plane cannot enforce is not offered. + continue + } + plan.WorkspaceLimit = limit + plans = append(plans, plan) + } + r.plans = plans + r.plansCachedAt = r.now() + return plans, nil +} + +// Checkout starts a Stripe checkout for this platform and returns its URL. +func (r *ProviderMSPLicenseRefresher) Checkout(ctx context.Context, planVersion, billingCycle string) (string, error) { + var resp struct { + URL string `json:"url"` + Error string `json:"error"` + Message string `json:"message"` + } + status, err := r.call(ctx, http.MethodPost, "/v1/provider-msp/checkout", map[string]string{ + "license_id": r.cfg.ProviderMSPLicenseID, + "plan_version": planVersion, + "billing_cycle": billingCycle, + "return_url": buildCPURL(r.cfg.BaseURL, portal.PortalPagePath, nil), + }, &resp) + if err != nil { + return "", err + } + switch { + case status == http.StatusOK && strings.HasPrefix(resp.URL, "https://"): + return resp.URL, nil + case status == http.StatusConflict: + return "", ErrProviderMSPAlreadySubscribed + default: + return "", fmt.Errorf("licence server refused checkout (HTTP %d)", status) + } +} + +// BillingPortal opens the Stripe billing portal for this platform's +// subscription and returns its URL. +func (r *ProviderMSPLicenseRefresher) BillingPortal(ctx context.Context) (string, error) { + request, err := r.signedRequest("billing-portal", buildCPURL(r.cfg.BaseURL, portal.PortalPagePath, nil)) + if err != nil { + return "", err + } + var resp struct { + URL string `json:"url"` + } + status, err := r.call(ctx, http.MethodPost, "/v1/provider-msp/billing-portal", request, &resp) + if err != nil { + return "", err + } + switch { + case status == http.StatusOK && strings.HasPrefix(resp.URL, "https://"): + return resp.URL, nil + case status == http.StatusNotFound: + return "", ErrProviderMSPNoSubscription + default: + return "", fmt.Errorf("licence server refused the billing portal (HTTP %d)", status) + } +} + +// ProviderMSPPlanState is what the portal shows about this platform's plan. +type ProviderMSPPlanState struct { + PlanVersion string `json:"plan_version"` + PlanSource string `json:"plan_source"` + Evaluation bool `json:"evaluation"` + LicenseID string `json:"license_id,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + WorkspaceLimit int `json:"workspace_limit"` + PurchaseAvailable bool `json:"purchase_available"` + Plans []ProviderMSPPurchasablePlan `json:"plans"` + PlansError string `json:"plans_error,omitempty"` +} + +func providerMSPPlanState(ctx context.Context, cfg *CPConfig, refresher *ProviderMSPLicenseRefresher) ProviderMSPPlanState { + plan := providerMSPPlanVersion(cfg) + limit, _ := pkglicensing.WorkspaceLimitForPlan(plan) + state := ProviderMSPPlanState{ + PlanVersion: plan, + PlanSource: providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource), + Evaluation: plan == pkglicensing.PlanVersionMSPEval, + LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID), + WorkspaceLimit: limit, + Plans: []ProviderMSPPurchasablePlan{}, + } + if !cfg.ProviderMSPLicenseExpiresAt.IsZero() { + state.ExpiresAt = cfg.ProviderMSPLicenseExpiresAt.Format(time.RFC3339) + } + if refresher == nil || state.LicenseID == "" { + return state + } + plans, err := refresher.Plans(ctx) + if err != nil { + state.PlansError = "Plans are unavailable right now. Try again in a few minutes." + return state + } + state.Plans = plans + state.PurchaseAvailable = len(plans) > 0 + return state +} + +func writeProviderMSPJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +// HandleProviderMSPPlan serves GET .../provider-msp/plan. +func HandleProviderMSPPlan(cfg *CPConfig, refresher *ProviderMSPLicenseRefresher) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + writeProviderMSPJSON(w, http.StatusOK, providerMSPPlanState(r.Context(), cfg, refresher)) + } +} + +// HandleProviderMSPCheckout serves POST .../provider-msp/checkout. +func HandleProviderMSPCheckout(refresher *ProviderMSPLicenseRefresher) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if refresher == nil { + writeProviderMSPJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "purchase_unavailable"}) + return + } + var req struct { + PlanVersion string `json:"plan_version"` + BillingCycle string `json:"billing_cycle"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil { + writeProviderMSPJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"}) + return + } + checkoutURL, err := refresher.Checkout(r.Context(), strings.TrimSpace(req.PlanVersion), strings.TrimSpace(req.BillingCycle)) + switch { + case errors.Is(err, ErrProviderMSPAlreadySubscribed): + writeProviderMSPJSON(w, http.StatusConflict, map[string]string{ + "error": "already_subscribed", + "message": "This platform already has a paid plan. Use Manage billing to change it.", + }) + case err != nil: + log.Warn().Err(err).Msg("Provider MSP checkout failed") + writeProviderMSPJSON(w, http.StatusBadGateway, map[string]string{ + "error": "checkout_unavailable", + "message": "Checkout is unavailable right now. Try again in a few minutes.", + }) + default: + writeProviderMSPJSON(w, http.StatusOK, map[string]string{"url": checkoutURL}) + } + } +} + +// HandleProviderMSPBillingPortal serves POST .../provider-msp/billing-portal. +func HandleProviderMSPBillingPortal(refresher *ProviderMSPLicenseRefresher) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if refresher == nil { + writeProviderMSPJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "billing_unavailable"}) + return + } + portalURL, err := refresher.BillingPortal(r.Context()) + switch { + case errors.Is(err, ErrProviderMSPNoSubscription): + writeProviderMSPJSON(w, http.StatusNotFound, map[string]string{ + "error": "no_paid_subscription", + "message": "This platform has no paid plan yet.", + }) + case err != nil: + log.Warn().Err(err).Msg("Provider MSP billing portal failed") + writeProviderMSPJSON(w, http.StatusBadGateway, map[string]string{ + "error": "billing_unavailable", + "message": "Billing is unavailable right now. Try again in a few minutes.", + }) + default: + writeProviderMSPJSON(w, http.StatusOK, map[string]string{"url": portalURL}) + } + } +} + +// HandleProviderMSPLicenseRefresh serves POST .../provider-msp/license/refresh, +// the "I've just paid" path that applies a purchase without waiting for the +// periodic refresh. +func HandleProviderMSPLicenseRefresh(refresher *ProviderMSPLicenseRefresher) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if refresher == nil { + writeProviderMSPJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "refresh_unavailable"}) + return + } + result, err := refresher.Refresh(r.Context()) + if err != nil { + log.Warn().Err(err).Msg("Provider MSP license refresh failed") + writeProviderMSPJSON(w, http.StatusBadGateway, map[string]string{ + "error": "refresh_unavailable", + "message": "The licence could not be refreshed right now. Try again in a few minutes.", + }) + return + } + writeProviderMSPJSON(w, http.StatusOK, result) + } +} diff --git a/internal/cloudcp/provider_msp_license_refresh_test.go b/internal/cloudcp/provider_msp_license_refresh_test.go new file mode 100644 index 0000000000..bde33c5c9e --- /dev/null +++ b/internal/cloudcp/provider_msp_license_refresh_test.go @@ -0,0 +1,290 @@ +package cloudcp + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" +) + +// providerMSPTestIssuer signs provider MSP licences with one licence-server +// key, so a test can hold an evaluation and a renewed licence at once. +type providerMSPTestIssuer struct { + private ed25519.PrivateKey +} + +func newProviderMSPTestIssuer(t *testing.T) *providerMSPTestIssuer { + t.Helper() + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + t.Setenv("PULSE_LICENSE_PUBLIC_KEY", base64.StdEncoding.EncodeToString(public)) + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + t.Cleanup(func() { pkglicensing.SetPublicKey(nil) }) + return &providerMSPTestIssuer{private: private} +} + +func (i *providerMSPTestIssuer) sign(t *testing.T, licenseID, planVersion string, expiresAt time.Time, binding ed25519.PublicKey) string { + t.Helper() + claims := pkglicensing.Claims{ + LicenseID: licenseID, + Email: "provider@example.com", + Tier: pkglicensing.TierMSP, + IssuedAt: time.Now().Add(-time.Minute).Unix(), + ExpiresAt: expiresAt.Unix(), + PlanVersion: planVersion, + EntitlementSigningPublicKey: base64.StdEncoding.EncodeToString(binding), + } + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`)) + payloadBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("Marshal claims: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + signature := base64.RawURLEncoding.EncodeToString(ed25519.Sign(i.private, []byte(header+"."+payload))) + return header + "." + payload + "." + signature +} + +func writeProviderMSPTestFile(t *testing.T, path, contents string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path +} + +type fakeProviderMSPLicenseServer struct { + t *testing.T + + mu sync.Mutex + currentStatus int + currentLicense string + checkoutStatus int + portalStatus int + plans string + lastCheckout map[string]string + signedActions []string +} + +func (f *fakeProviderMSPLicenseServer) verify(r *http.Request, action string) map[string]any { + f.t.Helper() + raw, _ := io.ReadAll(r.Body) + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + f.t.Fatalf("decode signed body: %v", err) + } + key, _ := base64.StdEncoding.DecodeString(body["entitlement_signing_public_key"].(string)) + sig, _ := base64.StdEncoding.DecodeString(body["signature"].(string)) + ts := int64(body["timestamp"].(float64)) + message := "pulse-provider-msp-v1\n" + action + "\n" + body["entitlement_signing_public_key"].(string) + "\n" + strconv.FormatInt(ts, 10) + if !ed25519.Verify(ed25519.PublicKey(key), []byte(message), sig) { + f.t.Fatalf("%s request is not signed by the lease signing key", action) + } + f.signedActions = append(f.signedActions, action) + return body +} + +func (f *fakeProviderMSPLicenseServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/provider-msp/license/current": + f.verify(r, "license-current") + w.WriteHeader(f.currentStatus) + if f.currentStatus == http.StatusOK { + _ = json.NewEncoder(w).Encode(map[string]string{"status": "active", "license": f.currentLicense, "paid_through": "2026-10-23T00:00:00Z"}) + } else { + _, _ = w.Write([]byte(`{"status":"no_paid_subscription"}`)) + } + case "/v1/provider-msp/billing-portal": + body := f.verify(r, "billing-portal") + if body["return_url"] != "https://msp.example.com/portal" { + f.t.Fatalf("billing portal return_url = %v", body["return_url"]) + } + w.WriteHeader(f.portalStatus) + _, _ = w.Write([]byte(`{"url":"https://billing.stripe.com/p/session/test"}`)) + case "/v1/provider-msp/checkout": + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &f.lastCheckout) + w.WriteHeader(f.checkoutStatus) + _, _ = w.Write([]byte(`{"url":"https://checkout.stripe.com/c/pay/cs_test"}`)) + case "/v1/provider-msp/plans": + _, _ = w.Write([]byte(f.plans)) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func newProviderMSPRefreshTestConfig(t *testing.T, serverURL string) *CPConfig { + t.Helper() + setTrialSigningEnv(t) + publicKey := base64.StdEncoding.EncodeToString(trialSigningEnvPublicKey(t)) + return &CPConfig{ + DataDir: t.TempDir(), + ControlPlaneMode: ControlPlaneModeProviderHostedMSP, + BaseURL: "https://msp.example.com", + LicenseServerURL: serverURL, + TrialActivationPrivateKey: "A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=", + TrialActivationPublicKey: publicKey, + ProviderMSPPlanVersion: pkglicensing.PlanVersionMSPEval, + ProviderMSPPlanSource: ProviderMSPPlanSourceLicenseFile, + ProviderMSPLicenseID: "lic_msp_eval", + ProviderMSPLeaseSigningPublicKey: publicKey, + ProviderMSPLicenseExpiresAt: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second), + } +} + +func TestProviderMSPLicenseRefreshInstallsThePaidLicenceAndRestarts(t *testing.T) { + issuer := newProviderMSPTestIssuer(t) + fake := &fakeProviderMSPLicenseServer{t: t, currentStatus: http.StatusNotFound} + server := httptest.NewServer(fake) + defer server.Close() + cfg := newProviderMSPRefreshTestConfig(t, server.URL) + refresher := NewProviderMSPLicenseRefresher(cfg) + restarted := make(chan struct{}, 1) + refresher.SetRestart(func() { restarted <- struct{}{} }) + + // No paid subscription: nothing installed, nothing restarted. + result, err := refresher.Refresh(context.Background()) + if err != nil || result.Status != "no_paid_subscription" || result.Changed { + t.Fatalf("unpaid refresh = %+v, %v", result, err) + } + if _, err := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); !os.IsNotExist(err) { + t.Fatalf("unpaid refresh wrote a licence: %v", err) + } + + // A licence bound to another platform's key is refused and not installed. + otherKey, _, _ := ed25519.GenerateKey(rand.Reader) + fake.currentStatus = http.StatusOK + fake.currentLicense = issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(45*24*time.Hour), otherKey) + if _, err := refresher.Refresh(context.Background()); err == nil || !strings.Contains(err.Error(), "another platform") { + t.Fatalf("foreign-key licence accepted: %v", err) + } + if _, err := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); !os.IsNotExist(err) { + t.Fatalf("foreign-key licence was installed: %v", err) + } + + // The paid licence for this key is installed and applied by a restart. + fake.currentLicense = issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(45*24*time.Hour), trialSigningEnvPublicKey(t)) + result, err = refresher.Refresh(context.Background()) + if err != nil || result.Status != "active" || !result.Changed || !result.RestartScheduled || result.PlanVersion != "msp_solo" { + t.Fatalf("paid refresh = %+v, %v", result, err) + } + installed, err := os.ReadFile(ProviderMSPRenewedLicensePath(cfg.DataDir)) + if err != nil || strings.TrimSpace(string(installed)) != fake.currentLicense { + t.Fatalf("installed licence mismatch: %v", err) + } + if info, _ := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); info.Mode().Perm() != 0o600 { + t.Fatalf("installed licence mode = %v", info.Mode().Perm()) + } + select { + case <-restarted: + case <-time.After(5 * time.Second): + t.Fatal("a changed licence did not restart the control plane") + } + + // Running on that licence, the same answer is not a change. + cfg.ProviderMSPLicenseID = "lic_msp_paid" + cfg.ProviderMSPPlanVersion = "msp_solo" + resolved, _ := resolveProviderMSPPlanFromLicenseFile(ProviderMSPRenewedLicensePath(cfg.DataDir)) + cfg.ProviderMSPLicenseExpiresAt = resolved.ExpiresAt + if result, err := refresher.Refresh(context.Background()); err != nil || result.Changed || result.RestartScheduled { + t.Fatalf("unchanged refresh = %+v, %v", result, err) + } +} + +func TestProviderMSPPortalRoutesRelayToTheLicenceServer(t *testing.T) { + fake := &fakeProviderMSPLicenseServer{ + t: t, + currentStatus: http.StatusNotFound, + checkoutStatus: http.StatusOK, + portalStatus: http.StatusOK, + plans: `{"plans":[{"plan_version":"msp_solo","billing_cycle":"monthly","unit_amount":6900,"currency":"usd"},{"plan_version":"msp_mystery","billing_cycle":"monthly","unit_amount":1,"currency":"usd"},{"plan_version":"msp_scale","billing_cycle":"annual","unit_amount":399000,"currency":"usd"}]}`, + } + server := httptest.NewServer(fake) + defer server.Close() + cfg := newProviderMSPRefreshTestConfig(t, server.URL) + refresher := NewProviderMSPLicenseRefresher(cfg) + + rec := httptest.NewRecorder() + HandleProviderMSPPlan(cfg, refresher)(rec, httptest.NewRequest(http.MethodGet, "/api/accounts/a_1/provider-msp/plan", nil)) + var state ProviderMSPPlanState + if err := json.Unmarshal(rec.Body.Bytes(), &state); err != nil { + t.Fatalf("decode plan state: %v", err) + } + if !state.Evaluation || state.WorkspaceLimit != 2 || !state.PurchaseAvailable || state.LicenseID != "lic_msp_eval" { + t.Fatalf("plan state = %+v", state) + } + if len(state.Plans) != 2 || state.Plans[0].PlanVersion != "msp_solo" || state.Plans[0].WorkspaceLimit != 3 || state.Plans[1].WorkspaceLimit != 40 { + t.Fatalf("offered plans = %+v (an unenforceable plan must be dropped)", state.Plans) + } + + rec = httptest.NewRecorder() + HandleProviderMSPCheckout(refresher)(rec, httptest.NewRequest(http.MethodPost, "/api/accounts/a_1/provider-msp/checkout", strings.NewReader(`{"plan_version":"msp_solo","billing_cycle":"monthly"}`))) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "checkout.stripe.com") { + t.Fatalf("checkout status=%d body=%s", rec.Code, rec.Body.String()) + } + if fake.lastCheckout["license_id"] != "lic_msp_eval" || fake.lastCheckout["return_url"] != "https://msp.example.com/portal" { + t.Fatalf("checkout request = %v", fake.lastCheckout) + } + + fake.checkoutStatus = http.StatusConflict + rec = httptest.NewRecorder() + HandleProviderMSPCheckout(refresher)(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"plan_version":"msp_solo","billing_cycle":"monthly"}`))) + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "already_subscribed") { + t.Fatalf("second checkout status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + HandleProviderMSPBillingPortal(refresher)(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "billing.stripe.com") { + t.Fatalf("billing portal status=%d body=%s", rec.Code, rec.Body.String()) + } + fake.portalStatus = http.StatusNotFound + rec = httptest.NewRecorder() + HandleProviderMSPBillingPortal(refresher)(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "no_paid_subscription") { + t.Fatalf("billing portal without a subscription status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + HandleProviderMSPLicenseRefresh(refresher)(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "no_paid_subscription") { + t.Fatalf("refresh status=%d body=%s", rec.Code, rec.Body.String()) + } + if strings.Join(fake.signedActions, ",") != "billing-portal,billing-portal,license-current" { + t.Fatalf("signed actions = %v", fake.signedActions) + } + + // Without a licence server the portal still reports the plan but offers + // no purchase, and the actions answer unavailable instead of failing open. + rec = httptest.NewRecorder() + HandleProviderMSPPlan(cfg, nil)(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + _ = json.Unmarshal(rec.Body.Bytes(), &state) + if state.PurchaseAvailable || len(state.Plans) != 0 { + t.Fatalf("plan state without refresher = %+v", state) + } + rec = httptest.NewRecorder() + HandleProviderMSPCheckout(nil)(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("checkout without refresher status=%d", rec.Code) + } +} diff --git a/internal/cloudcp/provider_msp_recovery.go b/internal/cloudcp/provider_msp_recovery.go index eeffd2e793..6a16126208 100644 --- a/internal/cloudcp/provider_msp_recovery.go +++ b/internal/cloudcp/provider_msp_recovery.go @@ -213,7 +213,7 @@ func validateProviderMSPRecoveryConfig(cfg *CPConfig, opts ProviderMSPRecoveryOp if _, known := pkglicensing.WorkspaceLimitForPlan(cfg.ProviderMSPPlanVersion); !known { return fmt.Errorf("provider MSP plan %q has no known workspace limit", cfg.ProviderMSPPlanVersion) } - if !opts.AllowEnvPlan && strings.TrimSpace(cfg.ProviderMSPPlanSource) != ProviderMSPPlanSourceLicenseFile { + if !opts.AllowEnvPlan && !ProviderMSPPlanSourceIsSignedLicense(cfg.ProviderMSPPlanSource) { return fmt.Errorf("provider MSP recovery requires %s plan source; rerun with --allow-env-plan only for local development", ProviderMSPPlanSourceLicenseFile) } return nil diff --git a/internal/cloudcp/routes.go b/internal/cloudcp/routes.go index d11925a7e7..958acaaeda 100644 --- a/internal/cloudcp/routes.go +++ b/internal/cloudcp/routes.go @@ -30,6 +30,7 @@ type Deps struct { HostedEntitlements *entitlements.Service Version string EmailSender email.Sender + ProviderMSPLicense *ProviderMSPLicenseRefresher // nil unless provider-hosted MSP can reach a licence server } func publicCloudSignupPath(cfg *CPConfig) string { @@ -282,6 +283,16 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) { handoffHandler := handoff.HandleHandoff(deps.Registry, deps.Config.TenantsDir()) mux.Handle("/api/accounts/{account_id}/tenants/{tenant_id}/handoff", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, handoffHandler))) + if deps.Config.IsProviderHostedMSP() { + // Plan, purchase and billing for a provider-hosted platform. Any + // member can see the plan; buying, billing and licence refresh are + // owner or admin actions. + mux.Handle("/api/accounts/{account_id}/provider-msp/plan", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, HandleProviderMSPPlan(deps.Config, deps.ProviderMSPLicense)))) + mux.Handle("/api/accounts/{account_id}/provider-msp/checkout", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, accountMutationAuth(HandleProviderMSPCheckout(deps.ProviderMSPLicense))))) + mux.Handle("/api/accounts/{account_id}/provider-msp/billing-portal", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, accountMutationAuth(HandleProviderMSPBillingPortal(deps.ProviderMSPLicense))))) + mux.Handle("/api/accounts/{account_id}/provider-msp/license/refresh", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, accountMutationAuth(HandleProviderMSPLicenseRefresh(deps.ProviderMSPLicense))))) + } + // MSP portal API (session + account-membership authenticated) portalEnv := portal.PortalEnvironment{ SignupPath: publicCloudSignupPath, diff --git a/internal/cloudcp/routes_auth_test.go b/internal/cloudcp/routes_auth_test.go index 056f502e56..64d11b2e7c 100644 --- a/internal/cloudcp/routes_auth_test.go +++ b/internal/cloudcp/routes_auth_test.go @@ -439,3 +439,85 @@ func TestRegisterRoutes_PortalPageSessionModes(t *testing.T) { } } } + +// On a provider-hosted platform any member may see the plan, but buying, +// opening billing and refreshing the licence are owner or admin actions. +func TestRegisterRoutes_ProviderMSPPurchaseRoutesRequireOwnerOrAdmin(t *testing.T) { + dir := t.TempDir() + reg, err := registry.NewTenantRegistry(dir) + if err != nil { + t.Fatalf("NewTenantRegistry: %v", err) + } + t.Cleanup(func() { _ = reg.Close() }) + accountID, err := registry.GenerateAccountID() + if err != nil { + t.Fatalf("GenerateAccountID: %v", err) + } + if err := reg.CreateAccount(®istry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Acme MSP"}); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + magicSvc, err := cpauth.NewService(dir) + if err != nil { + t.Fatalf("NewService: %v", err) + } + t.Cleanup(magicSvc.Close) + sessionFor := func(email string, role registry.MemberRole) string { + userID, err := registry.GenerateUserID() + if err != nil { + t.Fatalf("GenerateUserID: %v", err) + } + if err := reg.CreateUser(®istry.User{ID: userID, Email: email}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := reg.CreateMembership(®istry.AccountMembership{AccountID: accountID, UserID: userID, Role: role}); err != nil { + t.Fatalf("CreateMembership: %v", err) + } + token, err := magicSvc.GenerateSessionToken(userID, email, cpauth.SessionTTL) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + return token + } + owner := sessionFor("owner@example.com", registry.MemberRoleOwner) + readOnly := sessionFor("viewer@example.com", registry.MemberRoleReadOnly) + + mux := http.NewServeMux() + RegisterRoutes(mux, &Deps{ + Config: &CPConfig{ + DataDir: dir, + AdminKey: "test-admin-key", + BaseURL: "https://msp.example.com", + ControlPlaneMode: ControlPlaneModeProviderHostedMSP, + ProviderMSPPlanVersion: "msp_eval", + }, + Registry: reg, + MagicLinks: magicSvc, + Version: "test", + }) + call := func(method, path, token string) int { + req := httptest.NewRequest(method, "/api/accounts/"+accountID+"/provider-msp/"+path, strings.NewReader(`{"plan_version":"msp_solo","billing_cycle":"monthly"}`)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec.Code + } + + if got := call(http.MethodGet, "plan", ""); got != http.StatusUnauthorized { + t.Fatalf("unauthenticated plan status = %d", got) + } + if got := call(http.MethodGet, "plan", readOnly); got != http.StatusOK { + t.Fatalf("read-only member plan status = %d", got) + } + for _, path := range []string{"checkout", "billing-portal", "license/refresh"} { + if got := call(http.MethodPost, path, readOnly); got != http.StatusForbidden { + t.Errorf("read-only member %s status = %d, want 403", path, got) + } + // The owner reaches the handler, which answers unavailable because + // this platform has no licence server to relay to. + if got := call(http.MethodPost, path, owner); got != http.StatusServiceUnavailable { + t.Errorf("owner %s status = %d, want 503 from the handler", path, got) + } + } +} diff --git a/internal/cloudcp/server.go b/internal/cloudcp/server.go index 80ccc94e70..92da9c48ae 100644 --- a/internal/cloudcp/server.go +++ b/internal/cloudcp/server.go @@ -156,6 +156,7 @@ func Run(ctx context.Context, version string) error { HostedEntitlements: hostedEntitlements, Version: version, EmailSender: emailSender, + ProviderMSPLicense: NewProviderMSPLicenseRefresher(cfg), } RegisterRoutes(mux, deps) @@ -188,6 +189,15 @@ func Run(ctx context.Context, version string) error { stuckCleanup := NewStuckProvisioningCleanup(reg) go stuckCleanup.Run(ctx) + // Keep a provider-hosted platform's licence in step with its paid + // subscription. A changed licence cancels ctx, which shuts the server + // down cleanly; the container restart policy brings it back on the new + // licence. + if deps.ProviderMSPLicense != nil { + deps.ProviderMSPLicense.SetRestart(cancel) + go deps.ProviderMSPLicense.Run(ctx) + } + // Start metrics updater go runTenantStateMetrics(ctx, reg) diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index 9c79ded139..82b70a2e85 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -191,6 +191,7 @@ var CloudPlanWorkspaceLimits = map[string]int{ // MSP tiers — client caps from pricing spec "msp_eval": 2, // MSP evaluation: up to 2 clients, unlicensed, not sold + "msp_solo": 3, // MSP Solo: up to 3 clients, the first paid step above the evaluation "msp_starter": 5, // MSP Starter: up to 5 clients "msp_growth": 15, // MSP Growth: up to 15 clients "msp_scale": 40, // MSP Scale: up to 40 clients diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index 1a3da72d35..6e56e19e29 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -623,6 +623,7 @@ func TestWorkspaceLimitForPlan_KnownPlans(t *testing.T) { {"cloud_power", 1}, {"cloud_max", 1}, {"cloud_founding", 1}, + {"msp_solo", 3}, {"msp_starter", 5}, {"msp_hosted_v1", 5}, {"msp_growth", 15}, @@ -642,6 +643,24 @@ func TestWorkspaceLimitForPlan_KnownPlans(t *testing.T) { } } +// The provider MSP ladder must rise from the evaluation through every paid +// tier: the evaluation stays below the cheapest paid plan so needing one more +// client is what converts, and Solo is that first paid step. +func TestProviderMSPWorkspaceLadderRisesFromTheEvaluation(t *testing.T) { + ladder := []string{PlanVersionMSPEval, "msp_solo", "msp_starter", "msp_growth", "msp_scale"} + previous := 0 + for _, plan := range ladder { + limit, known := WorkspaceLimitForPlan(plan) + if !known { + t.Fatalf("%s has no workspace limit", plan) + } + if limit <= previous { + t.Fatalf("%s allows %d workspaces, not more than the step below it (%d)", plan, limit, previous) + } + previous = limit + } +} + func TestWorkspaceLimitForPlan_UnknownPlanFailsClosed(t *testing.T) { unknownPlans := []string{"stripe", "", "unknown_plan", "cloud_unknown"}