Let a provider-hosted platform buy and renew its own licence

A provider could stand the MSP bundle up and evaluate it without asking
anyone, but paying still meant emailing its public key to Pulse and
copying a licence file onto the host by hand. With the licence server's
self-serve purchase path (pulse-pro license-server/provider_msp_purchase),
the control plane can do the whole thing itself.

The control plane now signs a challenge with its lease signing key to
fetch the licence its subscription entitles, validates it exactly as
startup does, requires it to bind this platform's key, and keeps it in
the data directory, because the host licence is a read-only secret
mount. Startup prefers that renewed licence while it validates, so a
paying provider keeps starting after the self-issued evaluation on the
host expires. A changed licence restarts the control plane through the
existing graceful shutdown, since the plan version is read at load by
every workspace-limit, backup and status path; client workspaces keep
running. Preflight, proof, recovery and backup accept either signed
source.

The portal gains owner and admin routes for plan state, checkout, the
Stripe billing portal and an immediate licence refresh, and the plan list
comes from the licence server so it shows only what can be bought, at
Stripe's price. Adds the msp_solo plan: 3 client workspaces, the first
paid step above the 2-workspace evaluation.
This commit is contained in:
rcourtman
2026-09-23 23:30:34 +01:00
parent 1836341eae
commit 185c6ac80b
16 changed files with 1125 additions and 8 deletions
@@ -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)
}
@@ -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{
@@ -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)
}
@@ -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
@@ -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
+60 -4
View File
@@ -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) {
+48
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
@@ -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)
}
}
@@ -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)
}
}
+1 -1
View File
@@ -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
+11
View File
@@ -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,
+82
View File
@@ -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(&registry.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(&registry.User{ID: userID, Email: email}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := reg.CreateMembership(&registry.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)
}
}
}
+10
View File
@@ -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)
+1
View File
@@ -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
+19
View File
@@ -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"}