mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Keep the provider portal up when the MSP licence lapses
A provider-hosted control plane refused to start once its licence was past expiry and the 7-day grace, because startup validated it with ValidateLicense. So at the next restart after a 60-day evaluation ran out, or after a paying provider's subscription lapsed, the portal went down, and the portal's Plan tab is the only place a provider can buy or renew. Reproduced on the walkthrough lab. Startup now accepts an authentic, key-bound licence even when lapsed (ValidateLicenseAllowingLapse), preferring a current licence and otherwise the one that expired later, and logs the lapse. It unlocks nothing: client runtimes still verify the provider licence in every lease with ValidateLicense and drop MSP capabilities, new clients are refused with provider_msp_license_lapsed, and the refresher still refuses a lapsed licence. The Plan panel says the evaluation or plan ended and offers the plans, and provider-msp status reports license_lapsed without failing so a lapsed install can still upgrade. The same run showed the portal printing control plane error codes such as provider_msp_license_lapsed instead of their message. The portal now prefers an error's message over its code, which also fixes already_subscribed and checkout_unavailable in the Plan tab.
This commit is contained in:
parent
8c6ee59325
commit
951424d4ff
21 changed files with 455 additions and 44 deletions
|
|
@ -436,8 +436,9 @@ func (rt *providerMSPProofRuntime) createProviderMSPProofWorkspace(ctx context.C
|
|||
rt.registry,
|
||||
rt.provisioner,
|
||||
account.WorkspaceLimitPolicy{
|
||||
ProviderHostedMSP: true,
|
||||
ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg),
|
||||
ProviderHostedMSP: true,
|
||||
ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg),
|
||||
ProviderMSPLicenseLapsed: func() bool { return rt.cfg.ProviderMSPLicenseLapsed(time.Now()) },
|
||||
},
|
||||
)
|
||||
mux := http.NewServeMux()
|
||||
|
|
|
|||
|
|
@ -23,15 +23,18 @@ type providerMSPStatusOptions struct {
|
|||
}
|
||||
|
||||
type providerMSPStatusReport struct {
|
||||
OK bool
|
||||
Environment string
|
||||
ControlMode string
|
||||
BaseURL string
|
||||
PlanVersion string
|
||||
PlanSource string
|
||||
LicenseID string
|
||||
LicenseEmail string
|
||||
WorkspaceLimit int
|
||||
OK bool
|
||||
Environment string
|
||||
ControlMode string
|
||||
BaseURL string
|
||||
PlanVersion string
|
||||
PlanSource string
|
||||
LicenseID string
|
||||
LicenseEmail string
|
||||
WorkspaceLimit int
|
||||
// LicenseLapsed is informational, not a failure: a lapsed install must
|
||||
// still upgrade and keep its portal up so the provider can renew there.
|
||||
LicenseLapsed bool
|
||||
RegistryReady bool
|
||||
TotalTenants int
|
||||
HealthyTenants int
|
||||
|
|
@ -117,6 +120,7 @@ func runProviderMSPStatusWithDependencies(ctx context.Context, cfg *cloudcp.CPCo
|
|||
LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID),
|
||||
LicenseEmail: strings.ToLower(strings.TrimSpace(cfg.ProviderMSPLicenseEmail)),
|
||||
WorkspaceLimit: workspaceLimit,
|
||||
LicenseLapsed: cfg.ProviderMSPLicenseLapsed(deps.Now()),
|
||||
CountsByState: map[registry.TenantState]int{},
|
||||
}
|
||||
addFailure := func(format string, args ...any) {
|
||||
|
|
@ -399,6 +403,7 @@ func printProviderMSPStatusReport(report *providerMSPStatusReport) {
|
|||
fmt.Printf("license_id=%s\n", report.LicenseID)
|
||||
fmt.Printf("license_email=%s\n", report.LicenseEmail)
|
||||
fmt.Printf("workspace_limit=%d\n", report.WorkspaceLimit)
|
||||
fmt.Printf("license_lapsed=%t\n", report.LicenseLapsed)
|
||||
fmt.Printf("registry_ready=%t\n", report.RegistryReady)
|
||||
fmt.Printf("total_tenants=%d\n", report.TotalTenants)
|
||||
fmt.Printf("healthy_tenants=%d\n", report.HealthyTenants)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,30 @@ func TestProviderMSPStatusReportsHealthyOperatorSurface(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// A lapsed licence is reported, not failed: upgrade.sh gates on status, and a
|
||||
// lapsed install must still upgrade and keep its portal up to renew.
|
||||
func TestProviderMSPStatusReportsALapsedLicenceWithoutFailing(t *testing.T) {
|
||||
cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile)
|
||||
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
|
||||
cfg.ProviderMSPLicenseExpiresAt = now.Add(-30 * 24 * time.Hour)
|
||||
report, err := runProviderMSPStatusWithDependencies(context.Background(), cfg, providerMSPStatusOptions{}, providerMSPStatusDependencies{
|
||||
RunPreflight: func(context.Context, *cloudcp.CPConfig, providerMSPPreflightOptions) (*providerMSPPreflightReport, error) {
|
||||
return healthyProviderMSPStatusPreflightReport(), nil
|
||||
},
|
||||
NewDocker: healthyProviderMSPStatusDocker(map[string]bool{}),
|
||||
CheckBackup: func(context.Context, *cloudcp.CPConfig) (*providerMSPBackupStatus, error) {
|
||||
return healthyProviderMSPBackupStatus(now), nil
|
||||
},
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runProviderMSPStatusWithDependencies: %v", err)
|
||||
}
|
||||
if !report.OK || !report.LicenseLapsed {
|
||||
t.Fatalf("report OK=%v LicenseLapsed=%v failures=%v, want OK and lapsed", report.OK, report.LicenseLapsed, report.Failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMSPStatusFailsOnFailedUnhealthyAndStuckWorkspaces(t *testing.T) {
|
||||
cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile)
|
||||
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
|
|
|||
|
|
@ -3455,6 +3455,40 @@ Regression coverage:
|
|||
`TestEnsureTenantNetworkRejectsUnownedExistingNetwork` in
|
||||
`internal/cloudcp/docker/manager_test.go`.
|
||||
|
||||
### A provider control plane keeps its portal up on a lapsed licence
|
||||
|
||||
A provider-hosted control plane used to refuse to start once its licence was
|
||||
past expiry and the 7-day grace: `ValidateLicense` rejected it, so `LoadConfig`
|
||||
failed and the portal went down at the next restart. That is exactly when the
|
||||
provider needs the portal, because Plan is the only place to buy or renew;
|
||||
the same happened to a paying provider whose subscription lapsed. Startup now
|
||||
resolves an authentic, key-bound licence with
|
||||
`pkglicensing.ValidateLicenseAllowingLapse`, preferring a current licence
|
||||
(renewed first) and otherwise the lapsed one that expired later, and logs the
|
||||
lapse. Nothing is unlocked by this: client runtimes still verify the provider
|
||||
licence carried in each lease with `ValidateLicense` and drop MSP capabilities
|
||||
once it lapses, workspace creation refuses with `provider_msp_license_lapsed`,
|
||||
and the refresher still refuses to adopt a lapsed licence from the licence
|
||||
server. The Plan panel reads `lapsed` from the plan state, says the evaluation
|
||||
or plan ended and what that means for clients, and offers the plans, including
|
||||
the same plan again after a paid plan ends; `provider-msp status` prints
|
||||
`license_lapsed` without failing, so a lapsed install can still upgrade. The
|
||||
portal also shows a control plane error's `message` in preference to its
|
||||
machine `error` code, which had been surfacing codes such as
|
||||
`provider_msp_license_lapsed` and `already_subscribed` as the whole message.
|
||||
Verified on 2026-09-24 against the walkthrough lab: the control plane started
|
||||
on an evaluation that lapsed 20 days earlier, the Plan panel offered Solo and
|
||||
Starter, adding a client showed the lapse sentence, and with a live
|
||||
subscription the refresher restored the paid licence on its own. Regression
|
||||
coverage: `TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence`
|
||||
in `pkg/licensing/service_lapsed_test.go`,
|
||||
`TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence` in
|
||||
`internal/cloudcp/config_test.go`,
|
||||
`TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence` in
|
||||
`internal/cloudcp/account/tenant_handlers_lapsed_test.go`, and
|
||||
`TestProviderMSPLicenseRefreshRefusesALapsedLicence` in
|
||||
`internal/cloudcp/provider_msp_license_refresh_test.go`.
|
||||
|
||||
### Provider-hosted MSP platforms buy and renew their own licence
|
||||
|
||||
A provider-hosted control plane now buys and renews its licence through the
|
||||
|
|
|
|||
|
|
@ -2483,6 +2483,20 @@ unchanged. Regression coverage:
|
|||
`TestProviderMSPInstallProofCleansUpWorkspacesFromAPartialProof` in
|
||||
`cmd/pulse-control-plane/provider_msp_install_proof_test.go`.
|
||||
|
||||
### A lapsed provider MSP install still starts and upgrades
|
||||
|
||||
A provider-hosted install whose licence is past expiry and grace (an
|
||||
evaluation that ran out, or a paid plan that was not renewed) now starts its
|
||||
control plane instead of crash-looping on licence validation, so the portal
|
||||
and its Plan tab stay reachable to buy or renew. `provider-msp status`, which
|
||||
`upgrade.sh` gates on, prints `license_lapsed=true` as information rather than
|
||||
a failure, so such an install can still be upgraded. Client runtimes keep
|
||||
enforcing the lapse themselves and new clients are refused until a current
|
||||
licence is in place. Verified on 2026-09-24 on the walkthrough lab with an
|
||||
evaluation that lapsed 20 days earlier. Regression coverage:
|
||||
`TestProviderMSPStatusReportsALapsedLicenceWithoutFailing` in
|
||||
`cmd/pulse-control-plane/provider_msp_status_test.go`.
|
||||
|
||||
### Provider MSP setup points buyers at the portal
|
||||
|
||||
`deploy/provider-msp/setup.sh` no longer tells an evaluating provider to
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ type ownerAwareWorkspaceProvisioner interface {
|
|||
type WorkspaceLimitPolicy struct {
|
||||
ProviderHostedMSP bool
|
||||
ProviderMSPPlanVersion string
|
||||
// ProviderMSPLicenseLapsed reports whether the provider's licence is past
|
||||
// its expiry and grace period. The portal keeps running then, so the
|
||||
// provider can buy or renew, but no new client workspace may be created.
|
||||
ProviderMSPLicenseLapsed func() bool
|
||||
}
|
||||
|
||||
// HandleListTenants lists all tenants for an account.
|
||||
|
|
@ -271,6 +275,13 @@ func enforceWorkspaceLimit(reg *registry.TenantRegistry, account *registry.Accou
|
|||
usingProviderHostedPlan := false
|
||||
if sa == nil {
|
||||
if policy.ProviderHostedMSP && account != nil && account.Kind == registry.AccountKindMSP {
|
||||
if policy.ProviderMSPLicenseLapsed != nil && policy.ProviderMSPLicenseLapsed() {
|
||||
return &workspaceLimitError{
|
||||
reason: "provider_msp_license_lapsed",
|
||||
message: "Your Pulse MSP plan has ended, so no new clients can be added. Buy a plan from Plan in the portal to add clients again.",
|
||||
statusCode: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
planVersion = pkglicensing.CanonicalizePlanVersion(policy.ProviderMSPPlanVersion)
|
||||
usingProviderHostedPlan = true
|
||||
if strings.TrimSpace(planVersion) == "" {
|
||||
|
|
|
|||
|
|
@ -698,3 +698,42 @@ func TestCreateWorkspace_BlockedWhenSubscriptionCanceled(t *testing.T) {
|
|||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusForbidden, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A provider control plane keeps running on a lapsed licence so its portal can
|
||||
// sell the renewal, but it must not add clients until the provider buys.
|
||||
func TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence(t *testing.T) {
|
||||
reg := newTestRegistry(t)
|
||||
lapsed := true
|
||||
mux, _ := newTestTenantMuxWithWorkspaceLimitPolicy(
|
||||
t,
|
||||
reg,
|
||||
t.TempDir(),
|
||||
WorkspaceLimitPolicy{ProviderHostedMSP: true, ProviderMSPPlanVersion: "msp_eval", ProviderMSPLicenseLapsed: func() bool { return lapsed }},
|
||||
cpstripe.WithDefaultMSPPlanVersion("msp_eval"),
|
||||
)
|
||||
accountID, err := registry.GenerateAccountID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.CreateAccount(®istry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Provider MSP"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
create := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/accounts/"+accountID+"/tenants", bytes.NewBufferString(`{"display_name":"Acme Dental"}`))
|
||||
return doRequest(t, mux, req)
|
||||
}
|
||||
|
||||
rec := create()
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Buy a plan from Plan") {
|
||||
t.Fatalf("create on a lapsed licence = %d %q, want 403 pointing to Plan", rec.Code, rec.Body.String())
|
||||
}
|
||||
if count, _ := reg.CountActiveByAccountID(accountID); count != 0 {
|
||||
t.Fatalf("lapsed licence created %d workspaces", count)
|
||||
}
|
||||
|
||||
// Once the provider buys and the licence is current again, clients can be added.
|
||||
lapsed = false
|
||||
if rec := create(); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create after renewal = %d %q, want 201", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,6 +234,13 @@ func LoadConfig() (*CPConfig, error) {
|
|||
providerMSPLicenseEmail = resolved.LicenseEmail
|
||||
providerMSPLicenseKey = resolved.LicenseKey
|
||||
providerMSPLeaseSigningPublicKey = resolved.LeaseSigningPublicKey
|
||||
if resolved.lapsed(time.Now()) {
|
||||
log.Warn().
|
||||
Str("license_id", resolved.LicenseID).
|
||||
Str("plan_version", resolved.PlanVersion).
|
||||
Time("expired_at", resolved.ExpiresAt).
|
||||
Msg("Provider MSP license has lapsed: the portal stays up to sell the renewal, no new clients can be added, and client workspaces drop MSP capabilities")
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &CPConfig{
|
||||
|
|
@ -626,7 +633,14 @@ type providerMSPLicenseResolution struct {
|
|||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// resolveProviderMSPPlanFromLicenseFile validates a licence the control plane
|
||||
// is about to adopt, such as one the licence server just returned. It refuses
|
||||
// a lapsed licence.
|
||||
func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) {
|
||||
return resolveProviderMSPLicenseFile(path, false)
|
||||
}
|
||||
|
||||
func resolveProviderMSPLicenseFile(path string, allowLapsed bool) (*providerMSPLicenseResolution, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required")
|
||||
|
|
@ -636,7 +650,11 @@ func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseReso
|
|||
return nil, err
|
||||
}
|
||||
pkglicensing.InitEmbeddedPublicKey()
|
||||
license, err := pkglicensing.ValidateLicense(licenseKey)
|
||||
validate := pkglicensing.ValidateLicense
|
||||
if allowLapsed {
|
||||
validate = pkglicensing.ValidateLicenseAllowingLapse
|
||||
}
|
||||
license, err := validate(licenseKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
|
||||
}
|
||||
|
|
@ -689,26 +707,62 @@ func ProviderMSPRenewedLicensePath(dataDir string) string {
|
|||
return filepath.Join(dataDir, "control-plane", "provider-msp-license.jwt")
|
||||
}
|
||||
|
||||
// resolveProviderMSPLicense prefers a renewed licence that still validates
|
||||
// and falls back to CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence is
|
||||
// tried first because the host file is usually the self-issued evaluation,
|
||||
// which expires; a paying provider must keep starting after it does. The
|
||||
// lease signing key check in validate still ties either licence to this
|
||||
// control plane's private key.
|
||||
// lapsed reports whether the licence is past its expiry and grace period, the
|
||||
// point at which client runtimes stop accepting leases chained to it.
|
||||
func (r *providerMSPLicenseResolution) lapsed(now time.Time) bool {
|
||||
return providerMSPLicenseLapsed(r.ExpiresAt, now)
|
||||
}
|
||||
|
||||
func providerMSPLicenseLapsed(expiresAt, now time.Time) bool {
|
||||
return !expiresAt.IsZero() && now.After(expiresAt.Add(pkglicensing.DefaultGracePeriod))
|
||||
}
|
||||
|
||||
// ProviderMSPLicenseLapsed reports whether this platform's licence is past its
|
||||
// expiry and grace period. The control plane keeps running so its portal can
|
||||
// sell the renewal, but it adds no clients, and client runtimes drop MSP
|
||||
// capabilities on their own when they verify the lapsed licence.
|
||||
func (c *CPConfig) ProviderMSPLicenseLapsed(now time.Time) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return providerMSPLicenseLapsed(c.ProviderMSPLicenseExpiresAt, now)
|
||||
}
|
||||
|
||||
// resolveProviderMSPLicense picks the licence a provider-hosted control plane
|
||||
// starts on: a current renewed licence first, then a current
|
||||
// CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence wins because the host file
|
||||
// is usually the self-issued evaluation, which expires; a paying provider must
|
||||
// keep starting after it does. When neither is current it still starts, on
|
||||
// whichever lapsed licence expires later, because refusing to start would take
|
||||
// down the portal, which is the only place a provider can buy or renew. Both
|
||||
// candidates must be authentic Pulse licences, and validate still ties the
|
||||
// chosen one to this control plane's lease signing key.
|
||||
func resolveProviderMSPLicense(hostFile, dataDir string) (*providerMSPLicenseResolution, string, error) {
|
||||
now := time.Now()
|
||||
var renewed *providerMSPLicenseResolution
|
||||
renewedPath := ProviderMSPRenewedLicensePath(dataDir)
|
||||
if _, err := os.Stat(renewedPath); err == nil {
|
||||
renewed, err := resolveProviderMSPPlanFromLicenseFile(renewedPath)
|
||||
if err == nil {
|
||||
candidate, err := resolveProviderMSPLicenseFile(renewedPath, true)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE")
|
||||
} else {
|
||||
renewed = candidate
|
||||
}
|
||||
}
|
||||
if renewed != nil && !renewed.lapsed(now) {
|
||||
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
|
||||
}
|
||||
host, err := resolveProviderMSPLicenseFile(hostFile, true)
|
||||
if err != nil {
|
||||
if renewed != nil {
|
||||
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
|
||||
}
|
||||
log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE")
|
||||
}
|
||||
resolved, err := resolveProviderMSPPlanFromLicenseFile(hostFile)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resolved, ProviderMSPPlanSourceLicenseFile, nil
|
||||
if !host.lapsed(now) || renewed == nil || !renewed.ExpiresAt.After(host.ExpiresAt) {
|
||||
return host, ProviderMSPPlanSourceLicenseFile, nil
|
||||
}
|
||||
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
|
||||
}
|
||||
|
||||
func readProviderMSPLicenseFile(path string) (string, error) {
|
||||
|
|
|
|||
|
|
@ -991,6 +991,51 @@ func TestLoadConfig_SessionTTLProviderHostedDefault(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Refusing to start on a lapsed licence took the provider's portal down at the
|
||||
// moment the provider needed it to buy. The control plane now starts, reports
|
||||
// the licence as lapsed, and when both licences have lapsed it starts on the
|
||||
// one that expired later, so a lapsed paid plan reads as that plan.
|
||||
func TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence(t *testing.T) {
|
||||
setProviderHostedMSPEnv(t)
|
||||
t.Setenv("CP_ENV", "production")
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("CP_DATA_DIR", dataDir)
|
||||
issuer := newProviderMSPTestIssuer(t)
|
||||
key := trialSigningEnvPublicKey(t)
|
||||
hostFile := writeProviderMSPTestFile(t, filepath.Join(t.TempDir(), "eval.jwt"),
|
||||
issuer.sign(t, "lic_msp_eval", pkglicensing.PlanVersionMSPEval, time.Now().Add(-60*24*time.Hour), key))
|
||||
t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", hostFile)
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig with only a lapsed evaluation: %v", err)
|
||||
}
|
||||
if cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval || cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile {
|
||||
t.Fatalf("lapsed evaluation resolved plan=%q source=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource)
|
||||
}
|
||||
if !cfg.ProviderMSPLicenseLapsed(time.Now()) {
|
||||
t.Fatal("ProviderMSPLicenseLapsed = false for an evaluation that ended 60 days ago")
|
||||
}
|
||||
|
||||
writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir),
|
||||
issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-20*24*time.Hour), key))
|
||||
cfg, err = LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig with both licences lapsed: %v", err)
|
||||
}
|
||||
if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceRenewedLicense || cfg.ProviderMSPPlanVersion != "msp_solo" || !cfg.ProviderMSPLicenseLapsed(time.Now()) {
|
||||
t.Fatalf("both lapsed resolved plan=%q source=%q lapsed=%v, want the later-expiring paid licence",
|
||||
cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource, cfg.ProviderMSPLicenseLapsed(time.Now()))
|
||||
}
|
||||
|
||||
// Inside the 7-day grace the licence is expired but not yet lapsed.
|
||||
writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir),
|
||||
issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-2*24*time.Hour), key))
|
||||
if cfg, err = LoadConfig(); err != nil || cfg.ProviderMSPLicenseLapsed(time.Now()) {
|
||||
t.Fatalf("licence 2 days past expiry: err=%v lapsed=%v, want running and not lapsed", err, cfg != nil && cfg.ProviderMSPLicenseLapsed(time.Now()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_SessionTTLOverride(t *testing.T) {
|
||||
setProviderHostedMSPEnv(t)
|
||||
t.Setenv("CP_SESSION_TTL", "36h")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"source_hash": "33ddecf9ec76e0a22e0facf6971b8f0825600e56007db5e7f1f4e2cbe1dd2621",
|
||||
"source_hash": "5307f9f4c72602a934c10d38c5a20b212bddae474198b278648fc751df932267",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
|
|
|||
32
internal/cloudcp/portal/dist/portal_app.js
vendored
32
internal/cloudcp/portal/dist/portal_app.js
vendored
|
|
@ -1034,14 +1034,14 @@
|
|||
}
|
||||
function messageFromPayload(payload, fallback) {
|
||||
if (payload && typeof payload === "object") {
|
||||
var errorMessage = payload.error;
|
||||
if (typeof errorMessage === "string" && errorMessage.trim()) {
|
||||
return errorMessage;
|
||||
}
|
||||
var message = payload.message;
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
var errorMessage = payload.error;
|
||||
if (typeof errorMessage === "string" && errorMessage.trim()) {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
if (typeof payload === "string" && payload.trim()) {
|
||||
return payload;
|
||||
|
|
@ -1517,7 +1517,7 @@
|
|||
if (body.error !== "workspace_limit_reached") return "";
|
||||
var entity = clientLanguage ? "client workspaces" : "workspaces";
|
||||
var counts = typeof body.current === "number" && typeof body.limit === "number" && body.limit > 0 ? " (" + body.current + " of " + body.limit + " in use)" : "";
|
||||
return "Your license limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or upgrade your license to add more.";
|
||||
return "Your plan limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or move to a bigger plan to add more.";
|
||||
}
|
||||
var createWorkspace = async function(accountID) {
|
||||
var nameEl = getElement("ws-name-" + accountID);
|
||||
|
|
@ -2728,15 +2728,30 @@
|
|||
var expiresAt = new Date(plan.expires_at).getTime();
|
||||
return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS;
|
||||
}
|
||||
function planEnded(plan, now) {
|
||||
if (plan.lapsed) return true;
|
||||
if (!plan.expires_at) return false;
|
||||
var expiresAt = new Date(plan.expires_at).getTime();
|
||||
return !isNaN(expiresAt) && now >= expiresAt;
|
||||
}
|
||||
function renderCurrentPlan(plan, canManage, busy, inUse, now) {
|
||||
var expires = formatDate(plan.expires_at);
|
||||
var title = providerPlanName(plan.plan_version);
|
||||
var description;
|
||||
var meta = [inUse >= 0 ? String(inUse) + " of " + clientWorkspaces(plan.workspace_limit) + " in use" : clientWorkspaces(plan.workspace_limit)];
|
||||
var cta = "";
|
||||
if (plan.evaluation) {
|
||||
var ended = planEnded(plan, now);
|
||||
var lostFeatures = plan.lapsed ? " Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added." : "";
|
||||
if (plan.evaluation && ended) {
|
||||
description = "Your evaluation ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Buy a plan to keep your clients monitored and to add more.";
|
||||
} else if (plan.evaluation) {
|
||||
description = expires ? "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + " until " + expires + ". Buy a plan to keep your clients monitored after that and to add more." : "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + ". Buy a plan to add more.";
|
||||
if (expires) meta.push("Expires " + expires);
|
||||
} else if (ended) {
|
||||
description = "Your " + title + " plan ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Renew from Manage billing or buy a plan below.";
|
||||
if (canManage) {
|
||||
cta = '<button class="btn-secondary billing-action-button" type="button" data-provider-plan-action="manage-billing"' + (busy ? " disabled" : "") + ">" + (busy === "manage-billing" ? "Opening\u2026" : "Manage billing") + "</button>";
|
||||
}
|
||||
} else {
|
||||
description = paidLicenceInGrace(plan, now) ? "Your subscription has not renewed. Your clients keep this plan until " + expires + ". Open Manage billing to renew or update your payment method." : "Up to " + clientWorkspaces(plan.workspace_limit) + ". Renews automatically while your subscription is active.";
|
||||
if (canManage) {
|
||||
|
|
@ -2774,7 +2789,8 @@
|
|||
var plan = view.plan;
|
||||
if (!plan) return parts.join("");
|
||||
parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now));
|
||||
if (plan.evaluation) {
|
||||
var ended = planEnded(plan, now);
|
||||
if (plan.evaluation || ended) {
|
||||
if (plan.plans_error) {
|
||||
parts.push('<p class="billing-action-meta" role="alert">' + escapeText(plan.plans_error) + "</p>");
|
||||
} else if (plan.purchase_available) {
|
||||
|
|
@ -2783,7 +2799,7 @@
|
|||
});
|
||||
var cycle = hasAnnual ? view.cycle : "monthly";
|
||||
var offers = plan.plans.filter(function(option) {
|
||||
return option.billing_cycle === cycle && option.workspace_limit > plan.workspace_limit;
|
||||
return option.billing_cycle === cycle && (ended && !plan.evaluation ? option.workspace_limit >= plan.workspace_limit : option.workspace_limit > plan.workspace_limit);
|
||||
});
|
||||
parts.push('<div class="billing-section-intro"><h3>Choose a plan</h3><p>You pay per client workspace, never per monitored system. Every client workspace is full Pulse.</p></div>');
|
||||
parts.push(renderCycleToggle(cycle, hasAnnual));
|
||||
|
|
|
|||
|
|
@ -188,8 +188,8 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
|||
var counts = typeof body.current === 'number' && typeof body.limit === 'number' && body.limit > 0
|
||||
? ' (' + body.current + ' of ' + body.limit + ' in use)'
|
||||
: '';
|
||||
return 'Your license limit for ' + entity + ' is reached' + counts + '. Remove a ' +
|
||||
(clientLanguage ? 'client' : 'workspace') + ' or upgrade your license to add more.';
|
||||
return 'Your plan limit for ' + entity + ' is reached' + counts + '. Remove a ' +
|
||||
(clientLanguage ? 'client' : 'workspace') + ' or move to a bigger plan to add more.';
|
||||
}
|
||||
|
||||
var createWorkspace = async function(accountID: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,31 @@ describe('portal api', function() {
|
|||
});
|
||||
});
|
||||
|
||||
// A code-plus-sentence payload must show the sentence: the provider saw
|
||||
// "provider_msp_license_lapsed" instead of what to do about it.
|
||||
it('prefers the human message over the machine error code', async function() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async function() {
|
||||
return { error: 'provider_msp_license_lapsed', message: 'Your Pulse MSP plan has ended, so no new clients can be added.' };
|
||||
},
|
||||
}));
|
||||
|
||||
var api = createPortalAPI({
|
||||
getBootstrap: function() {
|
||||
return bootstrap;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(api.createWorkspace('acct_1', { display_name: 'Acme' })).rejects.toMatchObject({
|
||||
name: 'PortalAPIError',
|
||||
status: 403,
|
||||
message: 'Your Pulse MSP plan has ended, so no new clients can be added.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps task-specific fallback copy on network failures', async function() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
|
||||
|
||||
|
|
|
|||
|
|
@ -109,16 +109,19 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Control plane errors carry a machine code in "error" and, when there is
|
||||
// something to tell the person, a sentence in "message". Show the sentence;
|
||||
// older handlers put their only human text in "error", so fall back to it.
|
||||
function messageFromPayload(payload: unknown, fallback: string): string {
|
||||
if (payload && typeof payload === 'object') {
|
||||
var errorMessage = (payload as { error?: unknown }).error;
|
||||
if (typeof errorMessage === 'string' && errorMessage.trim()) {
|
||||
return errorMessage;
|
||||
}
|
||||
var message = (payload as { message?: unknown }).message;
|
||||
if (typeof message === 'string' && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
var errorMessage = (payload as { error?: unknown }).error;
|
||||
if (typeof errorMessage === 'string' && errorMessage.trim()) {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'string' && payload.trim()) {
|
||||
return payload;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,31 @@ describe('renderProviderPlanHTML', () => {
|
|||
expect(lapsed).not.toContain('Renews automatically');
|
||||
});
|
||||
|
||||
// The control plane keeps serving the portal on a lapsed licence precisely
|
||||
// so the provider can buy here; the panel must say so and offer the plans.
|
||||
it('tells a provider whose evaluation ended what happened and how to buy', () => {
|
||||
const now = Date.parse('2026-12-10T00:00:00Z');
|
||||
const html = renderProviderPlanHTML(view({
|
||||
plan: evaluationPlan({ expires_at: '2026-11-22T20:49:54Z', lapsed: true }),
|
||||
}), true, 2, now);
|
||||
expect(html).toContain('Your evaluation ended on');
|
||||
expect(html).toContain('lost their MSP features, and no new clients can be added');
|
||||
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_solo"');
|
||||
expect(html).not.toContain('Your evaluation covers');
|
||||
});
|
||||
|
||||
it('offers the same plan again after a paid plan lapses', () => {
|
||||
const now = Date.parse('2026-12-10T00:00:00Z');
|
||||
const html = renderProviderPlanHTML(view({
|
||||
plan: evaluationPlan({ plan_version: 'msp_solo', evaluation: false, workspace_limit: 3, expires_at: '2026-11-20T00:00:00Z', lapsed: true }),
|
||||
}), true, 3, now);
|
||||
expect(html).toContain('Your Solo plan ended on');
|
||||
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_solo"');
|
||||
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_starter"');
|
||||
expect(html).toContain('data-provider-plan-action="manage-billing"');
|
||||
expect(html).not.toContain('Renews automatically');
|
||||
});
|
||||
|
||||
it('keeps the evaluation visible when plans cannot be loaded', () => {
|
||||
const html = renderProviderPlanHTML(view({ plan: evaluationPlan({ plans: [], purchase_available: false, plans_error: 'Plans are unavailable right now.' }) }), true);
|
||||
expect(html).toContain('Free evaluation');
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ export interface ProviderPlanState {
|
|||
evaluation: boolean;
|
||||
license_id?: string;
|
||||
expires_at?: string;
|
||||
// Past expiry and grace: client workspaces have lost MSP features and no
|
||||
// new clients can be added until the provider buys or renews.
|
||||
lapsed?: boolean;
|
||||
workspace_limit: number;
|
||||
purchase_available: boolean;
|
||||
plans: ProviderPlanOption[];
|
||||
|
|
@ -80,17 +83,38 @@ function paidLicenceInGrace(plan: ProviderPlanState, now: number): boolean {
|
|||
return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS;
|
||||
}
|
||||
|
||||
// The licence has run out: the evaluation or paid period is over. The control
|
||||
// plane keeps serving this portal so the provider can buy or renew here.
|
||||
function planEnded(plan: ProviderPlanState, now: number): boolean {
|
||||
if (plan.lapsed) return true;
|
||||
if (!plan.expires_at) return false;
|
||||
var expiresAt = new Date(plan.expires_at).getTime();
|
||||
return !isNaN(expiresAt) && now >= expiresAt;
|
||||
}
|
||||
|
||||
function renderCurrentPlan(plan: ProviderPlanState, canManage: boolean, busy: string, inUse: number, now: number): string {
|
||||
var expires = formatDate(plan.expires_at);
|
||||
var title = providerPlanName(plan.plan_version);
|
||||
var description: string;
|
||||
var meta: string[] = [inUse >= 0 ? String(inUse) + ' of ' + clientWorkspaces(plan.workspace_limit) + ' in use' : clientWorkspaces(plan.workspace_limit)];
|
||||
var cta = '';
|
||||
if (plan.evaluation) {
|
||||
var ended = planEnded(plan, now);
|
||||
var lostFeatures = plan.lapsed
|
||||
? ' Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added.'
|
||||
: '';
|
||||
if (plan.evaluation && ended) {
|
||||
description = 'Your evaluation ended' + (expires ? ' on ' + expires : '') + '.' + lostFeatures + ' Buy a plan to keep your clients monitored and to add more.';
|
||||
} else if (plan.evaluation) {
|
||||
description = expires
|
||||
? 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + ' until ' + expires + '. Buy a plan to keep your clients monitored after that and to add more.'
|
||||
: 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + '. Buy a plan to add more.';
|
||||
if (expires) meta.push('Expires ' + expires);
|
||||
} else if (ended) {
|
||||
description = 'Your ' + title + ' plan ended' + (expires ? ' on ' + expires : '') + '.' + lostFeatures + ' Renew from Manage billing or buy a plan below.';
|
||||
if (canManage) {
|
||||
cta = '<button class="btn-secondary billing-action-button" type="button" data-provider-plan-action="manage-billing"' +
|
||||
(busy ? ' disabled' : '') + '>' + (busy === 'manage-billing' ? 'Opening…' : 'Manage billing') + '</button>';
|
||||
}
|
||||
} else {
|
||||
// The renewal date lives in Manage billing; the licence date only
|
||||
// matters, and only shows, once the subscription has stopped renewing.
|
||||
|
|
@ -162,14 +186,17 @@ export function renderProviderPlanHTML(view: ProviderPlanView, canManage: boolea
|
|||
if (!plan) return parts.join('');
|
||||
parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now));
|
||||
|
||||
if (plan.evaluation) {
|
||||
var ended = planEnded(plan, now);
|
||||
if (plan.evaluation || ended) {
|
||||
if (plan.plans_error) {
|
||||
parts.push('<p class="billing-action-meta" role="alert">' + escapeText(plan.plans_error) + '</p>');
|
||||
} else if (plan.purchase_available) {
|
||||
var hasAnnual = plan.plans.some(function(option) { return option.billing_cycle === 'annual'; });
|
||||
var cycle = hasAnnual ? view.cycle : 'monthly';
|
||||
// After a paid plan ends, buying the same plan again is a valid choice.
|
||||
var offers = plan.plans.filter(function(option) {
|
||||
return option.billing_cycle === cycle && option.workspace_limit > plan!.workspace_limit;
|
||||
return option.billing_cycle === cycle &&
|
||||
(ended && !plan!.evaluation ? option.workspace_limit >= plan!.workspace_limit : option.workspace_limit > plan!.workspace_limit);
|
||||
});
|
||||
parts.push('<div class="billing-section-intro"><h3>Choose a plan</h3><p>You pay per client workspace, never per monitored system. Every client workspace is full Pulse.</p></div>');
|
||||
parts.push(renderCycleToggle(cycle, hasAnnual));
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ type ProviderMSPPlanState struct {
|
|||
Evaluation bool `json:"evaluation"`
|
||||
LicenseID string `json:"license_id,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Lapsed bool `json:"lapsed"`
|
||||
WorkspaceLimit int `json:"workspace_limit"`
|
||||
PurchaseAvailable bool `json:"purchase_available"`
|
||||
Plans []ProviderMSPPurchasablePlan `json:"plans"`
|
||||
|
|
@ -409,6 +410,7 @@ func providerMSPPlanState(ctx context.Context, cfg *CPConfig, refresher *Provide
|
|||
if !cfg.ProviderMSPLicenseExpiresAt.IsZero() {
|
||||
state.ExpiresAt = cfg.ProviderMSPLicenseExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
state.Lapsed = cfg.ProviderMSPLicenseLapsed(time.Now())
|
||||
if refresher == nil || state.LicenseID == "" {
|
||||
return state
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,6 +244,26 @@ func TestProviderMSPLicenseRefreshAppliesShorterPaidPeriod(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Startup tolerates a lapsed licence so the portal stays up, but the refresher
|
||||
// must never adopt one the licence server returns.
|
||||
func TestProviderMSPLicenseRefreshRefusesALapsedLicence(t *testing.T) {
|
||||
issuer := newProviderMSPTestIssuer(t)
|
||||
fake := &fakeProviderMSPLicenseServer{t: t, currentStatus: http.StatusOK}
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
cfg := newProviderMSPRefreshTestConfig(t, server.URL)
|
||||
fake.currentLicense = issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-30*24*time.Hour), trialSigningEnvPublicKey(t))
|
||||
refresher := NewProviderMSPLicenseRefresher(cfg)
|
||||
refresher.SetRestart(func() { t.Fatal("a lapsed licence must not restart the control plane") })
|
||||
|
||||
if _, err := refresher.Refresh(context.Background()); err == nil || !strings.Contains(err.Error(), "unusable") {
|
||||
t.Fatalf("Refresh with a lapsed licence err = %v, want refusal", err)
|
||||
}
|
||||
if _, err := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("lapsed licence was installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMSPPortalRoutesRelayToTheLicenceServer(t *testing.T) {
|
||||
fake := &fakeProviderMSPLicenseServer{
|
||||
t: t,
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
|
|||
if deps.Config.IsMSPControlPlane() {
|
||||
workspaceLimitPolicy.ProviderHostedMSP = true
|
||||
workspaceLimitPolicy.ProviderMSPPlanVersion = providerMSPPlanVersion(deps.Config)
|
||||
workspaceLimitPolicy.ProviderMSPLicenseLapsed = func() bool { return deps.Config.ProviderMSPLicenseLapsed(time.Now()) }
|
||||
}
|
||||
createTenant := account.HandleCreateTenantWithWorkspaceLimitPolicy(deps.Registry, provisioner, workspaceLimitPolicy)
|
||||
updateTenant := account.HandleUpdateTenant(deps.Registry)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
package licensing
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeBillingStatePreservesMissingPlanVersionAndScrubsRetiredMonitoringLimit(t *testing.T) {
|
||||
state := &BillingState{
|
||||
|
|
@ -93,3 +101,43 @@ func TestCloudClaimsMissingPlanVersionDoesNotReintroduceMonitoringLimit(t *testi
|
|||
t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", claims.EffectiveLimits())
|
||||
}
|
||||
}
|
||||
|
||||
// A provider control plane must keep starting on a lapsed licence so its
|
||||
// portal can sell the renewal, but the licence must still be authentic, and
|
||||
// client runtimes must still refuse to take MSP capabilities from it.
|
||||
func TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence(t *testing.T) {
|
||||
setupTestPublicKey(t)
|
||||
providerPub, providerPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate provider key pair: %v", err)
|
||||
}
|
||||
lapsed := mintTestProviderMSPLicense(t, TierMSP, providerPub, -30*24*time.Hour)
|
||||
|
||||
if _, err := ValidateLicense(lapsed); !errors.Is(err, ErrExpiredLicense) {
|
||||
t.Fatalf("ValidateLicense(lapsed) error = %v, want ErrExpiredLicense", err)
|
||||
}
|
||||
license, err := ValidateLicenseAllowingLapse(lapsed)
|
||||
if err != nil || license == nil {
|
||||
t.Fatalf("ValidateLicenseAllowingLapse(lapsed) = %v, %v; want the licence", license, err)
|
||||
}
|
||||
if !license.IsExpired() || license.GracePeriodEnd == nil || !license.GracePeriodEnd.Before(time.Now()) {
|
||||
t.Fatalf("lapsed licence expired=%v graceEnd=%v; want expired with a grace end in the past", license.IsExpired(), license.GracePeriodEnd)
|
||||
}
|
||||
|
||||
// Authenticity is not relaxed: a licence not signed by the Pulse root is
|
||||
// still refused.
|
||||
claims := Claims{LicenseID: "lic_forged", Email: "attacker@example.com", Tier: TierMSP, IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: time.Now().Add(-30 * 24 * time.Hour).Unix(), PlanVersion: "msp_starter",
|
||||
EntitlementSigningPublicKey: base64.StdEncoding.EncodeToString(providerPub)}
|
||||
payload, _ := json.Marshal(claims)
|
||||
if _, err := ValidateLicenseAllowingLapse(signTestJWT(t, payload, providerPriv)); err == nil {
|
||||
t.Fatal("ValidateLicenseAllowingLapse accepted a licence not signed by the Pulse root")
|
||||
}
|
||||
|
||||
// Enforcement stays with the runtime: a lease chained to the lapsed
|
||||
// licence does not verify.
|
||||
token := signTestProviderLease(t, providerPriv, lapsed, []string{FeatureWhiteLabel, FeatureMultiTenant})
|
||||
if _, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()); err == nil {
|
||||
t.Fatal("a lease chained to a lapsed provider licence must not verify")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -935,6 +935,21 @@ func cloneClaims(in Claims) Claims {
|
|||
|
||||
// ValidateLicense validates a license key and returns the license if valid.
|
||||
func ValidateLicense(licenseKey string) (*License, error) {
|
||||
return validateLicense(licenseKey, false)
|
||||
}
|
||||
|
||||
// ValidateLicenseAllowingLapse applies every ValidateLicense check (format,
|
||||
// Pulse signature, required claims) but returns a licence past its expiry and
|
||||
// grace period instead of rejecting it, with GracePeriodEnd set. The caller
|
||||
// owns enforcement. A provider-hosted MSP control plane uses it to keep
|
||||
// starting on a lapsed licence so its portal can sell the renewal; the client
|
||||
// runtimes it serves still verify the licence with ValidateLicense and drop
|
||||
// MSP capabilities once it lapses.
|
||||
func ValidateLicenseAllowingLapse(licenseKey string) (*License, error) {
|
||||
return validateLicense(licenseKey, true)
|
||||
}
|
||||
|
||||
func validateLicense(licenseKey string, allowLapsed bool) (*License, error) {
|
||||
// Trim whitespace
|
||||
licenseKey = strings.TrimSpace(licenseKey)
|
||||
if licenseKey == "" {
|
||||
|
|
@ -1028,6 +1043,8 @@ func ValidateLicense(licenseKey string) (*License, error) {
|
|||
// Within grace period - allow activation but mark as in grace period
|
||||
license.GracePeriodEnd = &gracePeriodEnd
|
||||
// License is still valid during grace period
|
||||
} else if allowLapsed {
|
||||
license.GracePeriodEnd = &gracePeriodEnd
|
||||
} else {
|
||||
// Past grace period - reject
|
||||
return nil, fmt.Errorf("%w: expired on %s (grace period ended %s)",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue