From d25b99cbcc40aaaccdc9ae216721e5e13e2d5434 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 10 Jun 2026 13:58:52 +0100 Subject: [PATCH] Make provider-MSP client runtimes verifiably licensed via chained entitlement leases Provider-hosted MSP client workspaces previously sat at Community tier forever: the runtime refreshed leases against the built-in Pulse Cloud URL (hibernated, 522) instead of the provider control plane, and release-build images verify leases only against the embedded Pulse key, which an operator-generated CP_TRIAL_ACTIVATION_PRIVATE_KEY can never satisfy. - Inject PULSE_PRO_TRIAL_SIGNUP_URL=CP_BASE_URL into client containers so lease refresh targets the provider control plane. - Chain trust through the Pulse-signed provider MSP license: the license binds the provider's lease signing public key (entitlement_signing_public_key claim); the control plane embeds the license in every lease (provider_license claim); release-build runtimes verify embedded Pulse root -> provider license -> lease signature. - Cap chain-verified leases at ProviderChainedLeaseCapabilities: MSP tier plus white_label (branded per-client reports), minus Pulse-service-backed relay/mobile_app/push_notifications, which otherwise loop doomed registrations against Pulse's relay. - Fail fast at control-plane startup when the license does not bind the configured signing key, instead of provisioning silently unlicensed client workspaces. Verified live on a Colima harness: release-tagged tenant image with test embedded root, Traefik TLS, full provider-msp proof, tenant reports valid=true plan_version=msp_growth with white_label and zero relay failures. --- cmd/pulse-control-plane/mobile_proof_cmd.go | 1 + .../provider_msp_preflight.go | 1 + cmd/pulse-control-plane/provider_msp_proof.go | 3 + .../provider_msp_proof_test.go | 22 +- internal/cloudcp/cloud_audit.go | 1 + internal/cloudcp/config.go | 84 ++++++-- internal/cloudcp/config_test.go | 69 +++++- internal/cloudcp/docker/manager.go | 23 +- internal/cloudcp/docker/manager_test.go | 24 ++- internal/cloudcp/entitlements/service.go | 35 ++- internal/cloudcp/entitlements/service_test.go | 143 +++++++++++++ internal/cloudcp/routes.go | 3 + internal/cloudcp/server.go | 4 + internal/cloudcp/tenant_runtime_rollout.go | 1 + pkg/licensing/entitlement_lease.go | 100 +++++++++ pkg/licensing/models.go | 8 + pkg/licensing/provider_chain_test.go | 199 ++++++++++++++++++ 17 files changed, 688 insertions(+), 33 deletions(-) create mode 100644 pkg/licensing/provider_chain_test.go diff --git a/cmd/pulse-control-plane/mobile_proof_cmd.go b/cmd/pulse-control-plane/mobile_proof_cmd.go index 0bdbbd8d4..4d0acac8b 100644 --- a/cmd/pulse-control-plane/mobile_proof_cmd.go +++ b/cmd/pulse-control-plane/mobile_proof_cmd.go @@ -318,6 +318,7 @@ func newMobileProofRuntime(ctx context.Context) (*mobileProofRuntime, error) { Network: cfg.DockerNetwork, BaseDomain: mobileProofBaseDomainFromURL(cfg.BaseURL), TrialActivationPublicKey: cfg.TrialActivationPublicKey, + ControlPlaneBaseURL: cfg.BaseURL, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, MemoryLimit: cfg.TenantMemoryLimit, CPUShares: cfg.TenantCPUShares, diff --git a/cmd/pulse-control-plane/provider_msp_preflight.go b/cmd/pulse-control-plane/provider_msp_preflight.go index 507b68bac..36473588e 100644 --- a/cmd/pulse-control-plane/provider_msp_preflight.go +++ b/cmd/pulse-control-plane/provider_msp_preflight.go @@ -189,6 +189,7 @@ func providerMSPDockerManagerConfig(cfg *cloudcp.CPConfig) cpDocker.ManagerConfi IsolateTenantNetworks: true, BaseDomain: providerMSPBaseDomainFromURL(cfg.BaseURL), TrialActivationPublicKey: cfg.TrialActivationPublicKey, + ControlPlaneBaseURL: cfg.BaseURL, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, MemoryLimit: cfg.TenantMemoryLimit, CPUShares: cfg.TenantCPUShares, diff --git a/cmd/pulse-control-plane/provider_msp_proof.go b/cmd/pulse-control-plane/provider_msp_proof.go index 6ad0fa085..25844e6e8 100644 --- a/cmd/pulse-control-plane/provider_msp_proof.go +++ b/cmd/pulse-control-plane/provider_msp_proof.go @@ -179,6 +179,9 @@ func newProviderMSPProofRuntimeFromConfig(cfg *cloudcp.CPConfig) (*providerMSPPr } hostedEntitlements := entitlements.NewService(reg, cfg.BaseURL, cfg.TrialActivationPrivateKey) + if cfg.IsProviderHostedMSP() { + hostedEntitlements.SetProviderLicense(cfg.ProviderMSPLicenseKey) + } provisioner := cpstripe.NewProvisioner( reg, cfg.TenantsDir(), diff --git a/cmd/pulse-control-plane/provider_msp_proof_test.go b/cmd/pulse-control-plane/provider_msp_proof_test.go index 3c2df80ed..40656f122 100644 --- a/cmd/pulse-control-plane/provider_msp_proof_test.go +++ b/cmd/pulse-control-plane/provider_msp_proof_test.go @@ -312,13 +312,23 @@ func writeProviderMSPProofLicenseForTest(t *testing.T, licenseID, email, planVer t.Setenv("PULSE_LICENSE_DEV_MODE", "false") t.Cleanup(func() { pkglicensing.SetPublicKey(nil) }) + // Bind the lease signing key for the fixed CP_TRIAL_ACTIVATION_PRIVATE_KEY + // seed installed by setProviderMSPProofLoadConfigEnv; provider-hosted MSP + // config loading requires the license to bind the control plane's key. + leaseSigningSeed, err := base64.StdEncoding.DecodeString("A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=") + if err != nil { + t.Fatalf("decode lease signing seed: %v", err) + } + leaseSigningPublicKey := ed25519.NewKeyFromSeed(leaseSigningSeed).Public().(ed25519.PublicKey) + claims := pkglicensing.Claims{ - LicenseID: licenseID, - Email: email, - Tier: pkglicensing.TierMSP, - IssuedAt: time.Now().Add(-time.Minute).Unix(), - ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), - PlanVersion: planVersion, + LicenseID: licenseID, + Email: email, + Tier: pkglicensing.TierMSP, + IssuedAt: time.Now().Add(-time.Minute).Unix(), + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + PlanVersion: planVersion, + EntitlementSigningPublicKey: base64.StdEncoding.EncodeToString(leaseSigningPublicKey), } header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`)) payloadBytes, err := json.Marshal(claims) diff --git a/internal/cloudcp/cloud_audit.go b/internal/cloudcp/cloud_audit.go index 464beb6db..4970fb2b8 100644 --- a/internal/cloudcp/cloud_audit.go +++ b/internal/cloudcp/cloud_audit.go @@ -117,6 +117,7 @@ func AuditCloud(ctx context.Context, cfg *CPConfig) (*CloudAuditReport, error) { Network: cfg.DockerNetwork, BaseDomain: baseDomainFromURL(cfg.BaseURL), TrialActivationPublicKey: cfg.TrialActivationPublicKey, + ControlPlaneBaseURL: cfg.BaseURL, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, TenantReportBrand: cpDocker.TenantReportBrandConfig{ DisplayName: cfg.ReportBrandDisplayName, diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 98e4dda95..27aa8474b 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -83,6 +83,8 @@ type CPConfig struct { ProviderMSPLicenseFile string // Signed provider MSP license source ProviderMSPLicenseID string // Validated provider MSP license ID 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 LicenseServerURL string LicenseAdminToken string TrialActivationPrivateKey string @@ -189,15 +191,19 @@ func LoadConfig() (*CPConfig, error) { providerMSPPlanSource := ProviderMSPPlanSourceEnvFallback providerMSPLicenseID := "" providerMSPLicenseEmail := "" + providerMSPLicenseKey := "" + providerMSPLeaseSigningPublicKey := "" if isMSPControlPlaneMode(controlPlaneMode) && providerMSPLicenseFile != "" { - resolvedPlan, licenseID, licenseEmail, err := resolveProviderMSPPlanFromLicenseFile(providerMSPLicenseFile) + resolved, err := resolveProviderMSPPlanFromLicenseFile(providerMSPLicenseFile) if err != nil { return nil, fmt.Errorf("resolve provider MSP license: %w", err) } - providerMSPPlanVersion = resolvedPlan + providerMSPPlanVersion = resolved.PlanVersion providerMSPPlanSource = ProviderMSPPlanSourceLicenseFile - providerMSPLicenseID = licenseID - providerMSPLicenseEmail = licenseEmail + providerMSPLicenseID = resolved.LicenseID + providerMSPLicenseEmail = resolved.LicenseEmail + providerMSPLicenseKey = resolved.LicenseKey + providerMSPLeaseSigningPublicKey = resolved.LeaseSigningPublicKey } cfg := &CPConfig{ @@ -250,6 +256,8 @@ func LoadConfig() (*CPConfig, error) { ProviderMSPPlanVersion: providerMSPPlanVersion, ProviderMSPPlanSource: providerMSPPlanSource, ProviderMSPLicenseFile: providerMSPLicenseFile, + ProviderMSPLicenseKey: providerMSPLicenseKey, + ProviderMSPLeaseSigningPublicKey: providerMSPLeaseSigningPublicKey, ProviderMSPLicenseID: providerMSPLicenseID, ProviderMSPLicenseEmail: providerMSPLicenseEmail, LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"), @@ -271,9 +279,43 @@ func LoadConfig() (*CPConfig, error) { if err := cfg.validate(); err != nil { return nil, fmt.Errorf("validate control plane config: %w", err) } + + if err := cfg.validateProviderMSPLeaseSigningBinding(); err != nil { + return nil, err + } return cfg, nil } +// validateProviderMSPLeaseSigningBinding fails fast when a provider-hosted +// MSP control plane cannot mint entitlement leases that client runtimes will +// verify. Release-build tenant runtimes only trust lease signing keys bound +// into the Pulse-signed provider MSP license, so a missing or mismatched +// binding would provision client workspaces that silently run unlicensed. +func (c *CPConfig) validateProviderMSPLeaseSigningBinding() error { + if c == nil || !c.IsProviderHostedMSP() || strings.TrimSpace(c.ProviderMSPLicenseKey) == "" { + // Pulse-hosted modes sign with Pulse's own key, and the env-fallback + // plan path (dev/install-proof without a license file) has nothing + // to bind against. + return nil + } + boundKey := strings.TrimSpace(c.ProviderMSPLeaseSigningPublicKey) + if boundKey == "" { + return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE does not bind an entitlement lease signing key (entitlement_signing_public_key); request an updated provider MSP license bound to this control plane's CP_TRIAL_ACTIVATION_PRIVATE_KEY public key %q", c.TrialActivationPublicKey) + } + bound, err := pkglicensing.DecodePublicKey(boundKey) + if err != nil { + return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE entitlement_signing_public_key is malformed: %w", err) + } + derived, err := pkglicensing.DecodePublicKey(c.TrialActivationPublicKey) + if err != nil { + return fmt.Errorf("derive trial activation public key: %w", err) + } + if !bound.Equal(derived) { + return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY does not match the entitlement lease signing key bound in CP_PROVIDER_MSP_LICENSE_FILE (license binds %q, control plane derives %q); use the keypair the license was issued for, or request a license bound to this key", boundKey, c.TrialActivationPublicKey) + } + return nil +} + func deriveTrialActivationPublicKey(encodedPrivateKey string) (string, error) { privateKey, err := pkglicensing.DecodeEd25519PrivateKey(strings.TrimSpace(encodedPrivateKey)) if err != nil { @@ -537,37 +579,51 @@ func isMSPControlPlaneMode(mode ControlPlaneMode) bool { } } -func resolveProviderMSPPlanFromLicenseFile(path string) (planVersion, licenseID, licenseEmail string, err error) { +type providerMSPLicenseResolution struct { + PlanVersion string + LicenseID string + 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 +} + +func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) { path = strings.TrimSpace(path) if path == "" { - return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required") + return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required") } licenseKey, err := readProviderMSPLicenseFile(path) if err != nil { - return "", "", "", err + return nil, err } pkglicensing.InitEmbeddedPublicKey() license, err := pkglicensing.ValidateLicense(licenseKey) if err != nil { - return "", "", "", fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err) + return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err) } if license == nil { - return "", "", "", fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: no license returned") + return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: no license returned") } if license.Claims.Tier != pkglicensing.TierMSP { - return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE must contain an MSP license tier, got %q", license.Claims.Tier) + return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE must contain an MSP license tier, got %q", license.Claims.Tier) } plan := license.Claims.EntitlementPlanVersion() if strings.TrimSpace(plan) == "" { - return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE must contain plan_version") + return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE must contain plan_version") } if !strings.HasPrefix(strings.ToLower(plan), "msp_") { - return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must be a canonical MSP plan version, got %q", plan) + return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must be a canonical MSP plan version, got %q", plan) } if _, known := pkglicensing.WorkspaceLimitForPlan(plan); !known { - return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must have a known workspace limit, got %q", plan) + return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must have a known workspace limit, got %q", plan) } - return plan, strings.TrimSpace(license.Claims.LicenseID), strings.ToLower(strings.TrimSpace(license.Claims.Email)), nil + return &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 } func readProviderMSPLicenseFile(path string) (string, error) { diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index 9bad359b0..88795c08b 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -474,6 +474,22 @@ func setPulseHostedMSPEnv(t *testing.T) { } func writeProviderMSPLicenseForTest(t *testing.T, tier pkglicensing.Tier, planVersion string) string { + t.Helper() + return writeProviderMSPLicenseForTestWithBinding(t, tier, planVersion, nil) +} + +// trialSigningEnvPublicKey derives the public key for the fixed +// CP_TRIAL_ACTIVATION_PRIVATE_KEY seed installed by setTrialSigningEnv. +func trialSigningEnvPublicKey(t *testing.T) ed25519.PublicKey { + t.Helper() + seed, err := base64.StdEncoding.DecodeString("A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=") + if err != nil { + t.Fatalf("decode trial signing seed: %v", err) + } + return ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey) +} + +func writeProviderMSPLicenseForTestWithBinding(t *testing.T, tier pkglicensing.Tier, planVersion string, leaseSigningPublicKey ed25519.PublicKey) string { t.Helper() publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -491,6 +507,9 @@ func writeProviderMSPLicenseForTest(t *testing.T, tier pkglicensing.Tier, planVe ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), PlanVersion: planVersion, } + if len(leaseSigningPublicKey) > 0 { + claims.EntitlementSigningPublicKey = base64.StdEncoding.EncodeToString(leaseSigningPublicKey) + } header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`)) payloadBytes, err := json.Marshal(claims) if err != nil { @@ -698,7 +717,7 @@ func TestLoadConfig_ProviderHostedMSPUsesSignedLicenseFilePlan(t *testing.T) { setProviderHostedMSPEnv(t) t.Setenv("CP_ENV", "production") t.Setenv("CP_PROVIDER_MSP_PLAN_VERSION", "msp_starter") - t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", writeProviderMSPLicenseForTest(t, pkglicensing.TierMSP, "msp_growth")) + t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", writeProviderMSPLicenseForTestWithBinding(t, pkglicensing.TierMSP, "msp_growth", trialSigningEnvPublicKey(t))) cfg, err := LoadConfig() if err != nil { @@ -716,6 +735,54 @@ func TestLoadConfig_ProviderHostedMSPUsesSignedLicenseFilePlan(t *testing.T) { if cfg.ProviderMSPLicenseEmail != "provider@example.com" { t.Fatalf("ProviderMSPLicenseEmail = %q", cfg.ProviderMSPLicenseEmail) } + if strings.TrimSpace(cfg.ProviderMSPLicenseKey) == "" { + t.Fatal("ProviderMSPLicenseKey should retain the raw license for lease chaining") + } + if cfg.ProviderMSPLeaseSigningPublicKey != base64.StdEncoding.EncodeToString(trialSigningEnvPublicKey(t)) { + t.Fatalf("ProviderMSPLeaseSigningPublicKey = %q", cfg.ProviderMSPLeaseSigningPublicKey) + } +} + +func TestLoadConfig_ProviderHostedMSPRequiresLeaseSigningBinding(t *testing.T) { + setProviderHostedMSPEnv(t) + t.Setenv("CP_ENV", "production") + t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", writeProviderMSPLicenseForTest(t, pkglicensing.TierMSP, "msp_growth")) + + _, err := LoadConfig() + if err == nil { + t.Fatal("expected error when the provider MSP license does not bind a lease signing key") + } + if !strings.Contains(err.Error(), "entitlement_signing_public_key") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLoadConfig_ProviderHostedMSPRejectsMismatchedLeaseSigningKey(t *testing.T) { + setProviderHostedMSPEnv(t) + t.Setenv("CP_ENV", "production") + otherPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", writeProviderMSPLicenseForTestWithBinding(t, pkglicensing.TierMSP, "msp_growth", otherPub)) + + _, err = LoadConfig() + if err == nil { + t.Fatal("expected error when CP_TRIAL_ACTIVATION_PRIVATE_KEY does not match the license binding") + } + if !strings.Contains(err.Error(), "does not match the entitlement lease signing key") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLoadConfig_PulseHostedMSPDoesNotRequireLeaseSigningBinding(t *testing.T) { + setPulseHostedMSPEnv(t) + t.Setenv("CP_ENV", "production") + t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", writeProviderMSPLicenseForTest(t, pkglicensing.TierMSP, "msp_scale")) + + if _, err := LoadConfig(); err != nil { + t.Fatalf("Pulse-hosted MSP must accept licenses without a lease signing binding: %v", err) + } } func TestLoadConfig_ProviderHostedMSPRequiresLicenseFileInProduction(t *testing.T) { diff --git a/internal/cloudcp/docker/manager.go b/internal/cloudcp/docker/manager.go index 9ec45bc19..571094b4d 100644 --- a/internal/cloudcp/docker/manager.go +++ b/internal/cloudcp/docker/manager.go @@ -18,6 +18,8 @@ import ( "github.com/moby/moby/api/types/network" "github.com/moby/moby/client" "github.com/rs/zerolog/log" + + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) // ManagerConfig holds Docker manager settings. @@ -29,7 +31,12 @@ type ManagerConfig struct { SupportContainerLabels []string BaseDomain string TrialActivationPublicKey string - TrustedProxyCIDRs []string + // ControlPlaneBaseURL is the control plane's own external base URL + // (CP_BASE_URL). It is injected into client runtimes as the hosted + // commercial base URL so entitlement lease refresh targets this + // control plane instead of the built-in Pulse Cloud default. + ControlPlaneBaseURL string + TrustedProxyCIDRs []string // TenantDisplayName resolves a tenant's workspace display name at // container-create time; the value is injected as PULSE_TENANT_NAME so // alert webhook payloads carry a human-readable workspace label instead @@ -554,7 +561,7 @@ func tenantRuntimeContainerConfig(tenantID string, cfg ManagerConfig, labels map Image: cfg.Image, User: tenantRuntimeUserFor(cfg), Labels: labels, - Env: tenantEnvForRuntime(tenantID, tenantDisplayNameFor(cfg, tenantID), cfg.BaseDomain, cfg.TrialActivationPublicKey, trustedProxyCIDRs, tenantRuntimeUIDFor(cfg), tenantRuntimeGIDFor(cfg), cfg.TenantReportBrand), + Env: tenantEnvForRuntime(tenantID, tenantDisplayNameFor(cfg, tenantID), cfg.BaseDomain, cfg.TrialActivationPublicKey, cfg.ControlPlaneBaseURL, trustedProxyCIDRs, tenantRuntimeUIDFor(cfg), tenantRuntimeGIDFor(cfg), cfg.TenantReportBrand), } } @@ -640,11 +647,11 @@ func tenantRuntimeOwnershipPaths() []string { } } -func tenantEnv(tenantID, tenantName, baseDomain, trialActivationPublicKey string, trustedProxyCIDRs []string, reportBrand TenantReportBrandConfig) []string { - return tenantEnvForRuntime(tenantID, tenantName, baseDomain, trialActivationPublicKey, trustedProxyCIDRs, tenantRuntimeUID, tenantRuntimeGID, reportBrand) +func tenantEnv(tenantID, tenantName, baseDomain, trialActivationPublicKey, controlPlaneBaseURL string, trustedProxyCIDRs []string, reportBrand TenantReportBrandConfig) []string { + return tenantEnvForRuntime(tenantID, tenantName, baseDomain, trialActivationPublicKey, controlPlaneBaseURL, trustedProxyCIDRs, tenantRuntimeUID, tenantRuntimeGID, reportBrand) } -func tenantEnvForRuntime(tenantID, tenantName, baseDomain, trialActivationPublicKey string, trustedProxyCIDRs []string, runtimeUID, runtimeGID int, reportBrand TenantReportBrandConfig) []string { +func tenantEnvForRuntime(tenantID, tenantName, baseDomain, trialActivationPublicKey, controlPlaneBaseURL string, trustedProxyCIDRs []string, runtimeUID, runtimeGID int, reportBrand TenantReportBrandConfig) []string { routing := CanonicalTenantRuntimeRouting(tenantID, baseDomain) tenantID = strings.TrimSpace(tenantID) @@ -666,6 +673,12 @@ func tenantEnvForRuntime(tenantID, tenantName, baseDomain, trialActivationPublic if strings.TrimSpace(trialActivationPublicKey) != "" { env = append(env, "PULSE_TRIAL_ACTIVATION_PUBLIC_KEY="+strings.TrimSpace(trialActivationPublicKey)) } + if value := strings.TrimSpace(controlPlaneBaseURL); value != "" { + // Point hosted entitlement lease refresh at this control plane. + // Without it the runtime falls back to the built-in Pulse Cloud + // URL, which a provider-hosted control plane does not operate. + env = append(env, pkglicensing.ProTrialSignupURLEnvVar+"="+value) + } if len(trustedProxyCIDRs) > 0 { env = append(env, "PULSE_TRUSTED_PROXY_CIDRS="+strings.Join(trustedProxyCIDRs, ",")) } diff --git a/internal/cloudcp/docker/manager_test.go b/internal/cloudcp/docker/manager_test.go index d7046afe3..550b589e3 100644 --- a/internal/cloudcp/docker/manager_test.go +++ b/internal/cloudcp/docker/manager_test.go @@ -73,7 +73,7 @@ func TestTenantMountsKeepImmutableFilesReadOnly(t *testing.T) { func TestTenantEnvIncludesImmutableOwnershipContract(t *testing.T) { t.Parallel() - env := tenantEnv("t-example", "", "cloud.pulserelay.pro", "pubkey-123", []string{"172.18.0.0/16", "127.0.0.1/32"}, TenantReportBrandConfig{}) + env := tenantEnv("t-example", "", "cloud.pulserelay.pro", "pubkey-123", "https://cp.example-msp.com", []string{"172.18.0.0/16", "127.0.0.1/32"}, TenantReportBrandConfig{}) want := map[string]bool{ "PULSE_DATA_DIR=/etc/pulse": true, "PULSE_HOSTED_MODE=true": true, @@ -83,6 +83,7 @@ func TestTenantEnvIncludesImmutableOwnershipContract(t *testing.T) { "PGID=1000": true, "PULSE_PUBLIC_URL=https://t-example.cloud.pulserelay.pro": true, "PULSE_TRIAL_ACTIVATION_PUBLIC_KEY=pubkey-123": true, + "PULSE_PRO_TRIAL_SIGNUP_URL=https://cp.example-msp.com": true, "PULSE_TRUSTED_PROXY_CIDRS=172.18.0.0/16,127.0.0.1/32": true, immutableOwnershipPathsEnv + "=/etc/pulse/secrets/handoff.key:/etc/pulse/.cloud_handoff_key": true, } @@ -96,6 +97,17 @@ func TestTenantEnvIncludesImmutableOwnershipContract(t *testing.T) { } } +func TestTenantEnvOmitsEntitlementRefreshURLWhenUnset(t *testing.T) { + t.Parallel() + + env := tenantEnv("t-example", "", "cloud.pulserelay.pro", "", "", nil, TenantReportBrandConfig{}) + for _, item := range env { + if strings.HasPrefix(item, "PULSE_PRO_TRIAL_SIGNUP_URL=") { + t.Fatalf("PULSE_PRO_TRIAL_SIGNUP_URL should be omitted when no control plane base URL is configured, got %q", item) + } + } +} + func TestCanonicalTenantRuntimeRoutingLowercasesHostedAddressing(t *testing.T) { t.Parallel() @@ -181,7 +193,7 @@ func TestHealthCheckPrefersProviderMSPIsolatedTenantNetwork(t *testing.T) { func TestTenantEnvLowercasesPublicURLHost(t *testing.T) { t.Parallel() - env := tenantEnv("T-AbCd123", "", "Cloud.PulseRelay.Pro", "", nil, TenantReportBrandConfig{}) + env := tenantEnv("T-AbCd123", "", "Cloud.PulseRelay.Pro", "", "", nil, TenantReportBrandConfig{}) want := "PULSE_PUBLIC_URL=https://t-abcd123.cloud.pulserelay.pro" for _, item := range env { if item == want { @@ -194,7 +206,7 @@ func TestTenantEnvLowercasesPublicURLHost(t *testing.T) { func TestTenantEnvOmitsPublicURLWithoutTenantContext(t *testing.T) { t.Parallel() - env := tenantEnv("", "", "", "", nil, TenantReportBrandConfig{}) + env := tenantEnv("", "", "", "", "", nil, TenantReportBrandConfig{}) sawTenantID := false for _, item := range env { if strings.HasPrefix(item, "PULSE_PUBLIC_URL=") { @@ -212,7 +224,7 @@ func TestTenantEnvOmitsPublicURLWithoutTenantContext(t *testing.T) { func TestTenantEnvStampsWorkspaceDisplayName(t *testing.T) { t.Parallel() - env := tenantEnv("t-acme", " Acme Rentals ", "cloud.pulserelay.pro", "", nil, TenantReportBrandConfig{}) + env := tenantEnv("t-acme", " Acme Rentals ", "cloud.pulserelay.pro", "", "", nil, TenantReportBrandConfig{}) want := "PULSE_TENANT_NAME=Acme Rentals" for _, item := range env { if item == want { @@ -225,7 +237,7 @@ func TestTenantEnvStampsWorkspaceDisplayName(t *testing.T) { func TestTenantEnvOmitsTenantNameWhenUnresolved(t *testing.T) { t.Parallel() - env := tenantEnv("t-acme", "", "cloud.pulserelay.pro", "", nil, TenantReportBrandConfig{}) + env := tenantEnv("t-acme", "", "cloud.pulserelay.pro", "", "", nil, TenantReportBrandConfig{}) for _, item := range env { if strings.HasPrefix(item, "PULSE_TENANT_NAME=") { t.Fatalf("unexpected tenant name env item %q; name must be omitted so payloads fall back to the tenant ID", item) @@ -262,7 +274,7 @@ func TestTenantRuntimeContainerConfigResolvesTenantDisplayName(t *testing.T) { func TestTenantEnvIncludesProviderReportBrandDefaults(t *testing.T) { t.Parallel() - env := tenantEnv("t-acme", "", "msp.example.com", "", nil, TenantReportBrandConfig{ + env := tenantEnv("t-acme", "", "msp.example.com", "", "", nil, TenantReportBrandConfig{ DisplayName: "Acme Managed IT", LogoBase64: "iVBORw0KGgo=", LogoFormat: "png", diff --git a/internal/cloudcp/entitlements/service.go b/internal/cloudcp/entitlements/service.go index fdc1a3c67..bcd838b22 100644 --- a/internal/cloudcp/entitlements/service.go +++ b/internal/cloudcp/entitlements/service.go @@ -27,6 +27,7 @@ type Service struct { registry *registry.TenantRegistry baseURL string trialActivationPrivateKey string + providerLicense string now func() time.Time } @@ -50,6 +51,17 @@ func (s *Service) SetNow(now func() time.Time) { s.now = func() time.Time { return now().UTC() } } +// SetProviderLicense chains the Pulse-signed provider MSP license into every +// lease this service signs. Release-build client runtimes only trust lease +// signing keys bound into such a license, so provider-hosted control planes +// must set this for their leases to verify. +func (s *Service) SetProviderLicense(licenseKey string) { + if s == nil { + return + } + s.providerLicense = strings.TrimSpace(licenseKey) +} + func (s *Service) IssueTenantBillingState(tenant *registry.Tenant, requestedSubState pkglicensing.SubscriptionState, requestedPlanVersion, stripeCustomerID, stripeSubscriptionID, stripePriceID string) (*pkglicensing.BillingState, error) { if s == nil || s.registry == nil { return nil, fmt.Errorf("hosted entitlement service is unavailable") @@ -142,6 +154,7 @@ type tenantLeaseContext struct { stripeAccount *registry.StripeAccount subscriptionState pkglicensing.SubscriptionState planVersion string + providerChained bool } type trialEntitlementContext struct { @@ -234,6 +247,7 @@ func (s *Service) resolveTenantLeaseContext(tenant *registry.Tenant, requestedSu tenant: tenant, subscriptionState: requestedSubState, planVersion: pkglicensing.CanonicalizePlanVersion(requestedPlanVersion), + providerChained: strings.TrimSpace(s.providerLicense) != "", } if ctx.planVersion == "" { ctx.planVersion = pkglicensing.CanonicalizePlanVersion(tenant.PlanVersion) @@ -296,6 +310,7 @@ func (s *Service) signPaidLease(ctx *tenantLeaseContext, instanceHost string, no return "", err } claims := buildPaidEntitlementLeaseClaims(ctx, instanceHost, now) + claims.ProviderLicense = s.providerLicense signed, err := pkglicensing.SignEntitlementLeaseToken(privateKey, claims) if err != nil { return "", fmt.Errorf("sign hosted entitlement lease: %w", err) @@ -332,6 +347,10 @@ func randomRefreshToken() (string, error) { return "etr_hosted_" + hex.EncodeToString(raw), nil } +func isMSPPlanVersion(planVersion string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(planVersion)), "msp_") +} + func tenantSubscriptionState(tenant *registry.Tenant) pkglicensing.SubscriptionState { if tenant == nil { return pkglicensing.SubStateExpired @@ -349,7 +368,21 @@ func tenantSubscriptionState(tenant *registry.Tenant) pkglicensing.SubscriptionS func buildPaidEntitlementLeaseClaims(ctx *tenantLeaseContext, instanceHost string, now time.Time) pkglicensing.EntitlementLeaseClaims { var capabilities []string if pkglicensing.ShouldGrantPaidCapabilities(ctx.subscriptionState) { - capabilities = pkglicensing.DeriveCapabilitiesFromTier(pkglicensing.TierCloud, nil) + switch { + case isMSPPlanVersion(ctx.planVersion) && ctx.providerChained: + // Provider-hosted MSP client workspaces lease the chained + // ceiling: MSP tier plus white_label (branded per-client + // reports), minus Pulse-service-backed capabilities (relay, + // mobile, push) the provider deployment cannot grant. Mirrors + // the chain-verification cap in pkg/licensing. + capabilities = pkglicensing.ProviderChainedLeaseCapabilities() + case isMSPPlanVersion(ctx.planVersion): + // Pulse-hosted MSP workspaces run against Pulse-operated + // services, so they keep the full MSP tier set. + capabilities = pkglicensing.DeriveCapabilitiesFromTier(pkglicensing.TierMSP, []string{pkglicensing.FeatureWhiteLabel}) + default: + capabilities = pkglicensing.DeriveCapabilitiesFromTier(pkglicensing.TierCloud, nil) + } } claims := pkglicensing.EntitlementLeaseClaims{ OrgID: "default", diff --git a/internal/cloudcp/entitlements/service_test.go b/internal/cloudcp/entitlements/service_test.go index 403debeda..a4e6cd2e9 100644 --- a/internal/cloudcp/entitlements/service_test.go +++ b/internal/cloudcp/entitlements/service_test.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "database/sql" "encoding/base64" + "encoding/json" "path/filepath" "testing" "time" @@ -420,3 +421,145 @@ func seedLegacyTrialHostedEntitlement(t *testing.T, registryDir string, now time t.Fatalf("seed legacy trial hosted entitlement: %v", err) } } + +// mintChainTestProviderLicense installs a test license root and returns a +// Pulse-signed MSP license binding the given lease signing public key, so +// chained lease verification works end-to-end in service tests. +func mintChainTestProviderLicense(t *testing.T, leaseSigningPublicKey ed25519.PublicKey) string { + t.Helper() + + rootPub, rootPriv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey root: %v", err) + } + pkglicensing.SetPublicKey(rootPub) + t.Cleanup(func() { pkglicensing.SetPublicKey(nil) }) + + claims := pkglicensing.Claims{ + LicenseID: "lic_msp_lease_shape_test", + Email: "provider@example-msp.com", + Tier: pkglicensing.TierMSP, + IssuedAt: time.Now().Add(-time.Minute).Unix(), + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + PlanVersion: "msp_starter", + EntitlementSigningPublicKey: base64.StdEncoding.EncodeToString(leaseSigningPublicKey), + } + payloadBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal provider license claims: %v", err) + } + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + signature := base64.RawURLEncoding.EncodeToString(ed25519.Sign(rootPriv, []byte(header+"."+payload))) + return header + "." + payload + "." + signature +} + +func TestIssueTenantBillingStateMSPWorkspaceLeaseShape(t *testing.T) { + svc, pub, reg := newTestService(t) + providerLicense := mintChainTestProviderLicense(t, pub) + svc.SetProviderLicense(providerLicense) + + accountID, err := registry.GenerateAccountID() + if err != nil { + t.Fatalf("GenerateAccountID: %v", err) + } + if err := reg.CreateAccount(®istry.Account{ + ID: accountID, + Kind: registry.AccountKindMSP, + DisplayName: "Example MSP", + }); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + tenant := ®istry.Tenant{ + ID: "t-MSPCLIENT1", + AccountID: accountID, + Email: "owner@example-msp.com", + State: registry.TenantStateActive, + PlanVersion: "msp_starter", + } + if err := reg.Create(tenant); err != nil { + t.Fatalf("Create: %v", err) + } + + now := time.Unix(1710000000, 0).UTC() + svc.SetNow(func() time.Time { return now }) + + state, err := svc.IssueTenantBillingState(tenant, pkglicensing.SubStateActive, "msp_starter", "", "", "") + if err != nil { + t.Fatalf("IssueTenantBillingState: %v", err) + } + claims, err := pkglicensing.VerifyEntitlementLeaseToken(state.EntitlementJWT, pub, "t-MSPCLIENT1.cloud.example.com", now) + if err != nil { + t.Fatalf("VerifyEntitlementLeaseToken: %v", err) + } + if claims.ProviderLicense != providerLicense { + t.Fatalf("claims.ProviderLicense = %q, want chained provider license", claims.ProviderLicense) + } + capabilities := map[string]bool{} + for _, capability := range claims.Capabilities { + capabilities[capability] = true + } + if !capabilities[pkglicensing.FeatureWhiteLabel] { + t.Fatalf("MSP workspace lease must carry white_label for branded client reports, got %v", claims.Capabilities) + } + if !capabilities[pkglicensing.FeatureMultiTenant] { + t.Fatalf("MSP workspace lease missing MSP tier capability multi_tenant: %v", claims.Capabilities) + } + if capabilities[pkglicensing.FeatureMultiUser] { + t.Fatalf("MSP workspace lease must not carry enterprise-only multi_user: %v", claims.Capabilities) + } + for _, serviceBacked := range []string{pkglicensing.FeatureRelay, pkglicensing.FeatureMobileApp, pkglicensing.FeaturePushNotifications} { + if capabilities[serviceBacked] { + t.Fatalf("provider-chained MSP lease must not grant Pulse-service-backed capability %q: %v", serviceBacked, claims.Capabilities) + } + } +} + +func TestIssueTenantBillingStateCloudPlanOmitsProviderLicenseWhenUnset(t *testing.T) { + svc, pub, reg := newTestService(t) + + accountID, err := registry.GenerateAccountID() + if err != nil { + t.Fatalf("GenerateAccountID: %v", err) + } + if err := reg.CreateAccount(®istry.Account{ + ID: accountID, + Kind: registry.AccountKindIndividual, + DisplayName: "Pulse Labs", + }); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + tenant := ®istry.Tenant{ + ID: "t-CLOUD01", + AccountID: accountID, + Email: "owner@example.com", + State: registry.TenantStateActive, + PlanVersion: "cloud_starter", + } + if err := reg.Create(tenant); err != nil { + t.Fatalf("Create: %v", err) + } + + now := time.Unix(1710000000, 0).UTC() + svc.SetNow(func() time.Time { return now }) + + state, err := svc.IssueTenantBillingState(tenant, pkglicensing.SubStateActive, "cloud_starter", "", "", "") + if err != nil { + t.Fatalf("IssueTenantBillingState: %v", err) + } + claims, err := pkglicensing.VerifyEntitlementLeaseToken(state.EntitlementJWT, pub, "t-CLOUD01.cloud.example.com", now) + if err != nil { + t.Fatalf("VerifyEntitlementLeaseToken: %v", err) + } + if claims.ProviderLicense != "" { + t.Fatalf("cloud lease should not chain a provider license, got %q", claims.ProviderLicense) + } + capabilities := map[string]bool{} + for _, capability := range claims.Capabilities { + capabilities[capability] = true + } + if capabilities[pkglicensing.FeatureWhiteLabel] { + t.Fatalf("cloud lease should not carry white_label, got %v", claims.Capabilities) + } +} diff --git a/internal/cloudcp/routes.go b/internal/cloudcp/routes.go index a4d3db151..7058eca6b 100644 --- a/internal/cloudcp/routes.go +++ b/internal/cloudcp/routes.go @@ -138,6 +138,9 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) { hostedEntitlements := deps.HostedEntitlements if hostedEntitlements == nil { hostedEntitlements = entitlements.NewService(deps.Registry, deps.Config.BaseURL, deps.Config.TrialActivationPrivateKey) + if deps.Config.IsProviderHostedMSP() { + hostedEntitlements.SetProviderLicense(deps.Config.ProviderMSPLicenseKey) + } } provisioner := deps.Provisioner if provisioner == nil { diff --git a/internal/cloudcp/server.go b/internal/cloudcp/server.go index aaddcef80..b60760361 100644 --- a/internal/cloudcp/server.go +++ b/internal/cloudcp/server.go @@ -59,6 +59,7 @@ func Run(ctx context.Context, version string) error { IsolateTenantNetworks: cfg.IsMSPControlPlane(), BaseDomain: baseDomainFromURL(cfg.BaseURL), TrialActivationPublicKey: cfg.TrialActivationPublicKey, + ControlPlaneBaseURL: cfg.BaseURL, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, TenantDisplayName: tenantDisplayNameResolver(reg), TenantReportBrand: cpDocker.TenantReportBrandConfig{ @@ -112,6 +113,9 @@ func Run(ctx context.Context, version string) error { // Build HTTP routes mux := http.NewServeMux() hostedEntitlements := entitlements.NewService(reg, cfg.BaseURL, cfg.TrialActivationPrivateKey) + if cfg.IsProviderHostedMSP() { + hostedEntitlements.SetProviderLicense(cfg.ProviderMSPLicenseKey) + } provisioner := cpstripe.NewProvisioner( reg, cfg.TenantsDir(), diff --git a/internal/cloudcp/tenant_runtime_rollout.go b/internal/cloudcp/tenant_runtime_rollout.go index 51290449d..69ee00aa7 100644 --- a/internal/cloudcp/tenant_runtime_rollout.go +++ b/internal/cloudcp/tenant_runtime_rollout.go @@ -221,6 +221,7 @@ func newTenantRuntimeRolloutServiceFromConfig( IsolateTenantNetworks: cfg.IsMSPControlPlane(), BaseDomain: baseDomainFromURL(cfg.BaseURL), TrialActivationPublicKey: cfg.TrialActivationPublicKey, + ControlPlaneBaseURL: cfg.BaseURL, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, TenantDisplayName: tenantDisplayNameResolver(reg), TenantReportBrand: cpDocker.TenantReportBrandConfig{ diff --git a/pkg/licensing/entitlement_lease.go b/pkg/licensing/entitlement_lease.go index 5ba61c96d..d02cfee24 100644 --- a/pkg/licensing/entitlement_lease.go +++ b/pkg/licensing/entitlement_lease.go @@ -45,6 +45,15 @@ type EntitlementLeaseClaims struct { MetersEnabled []string `json:"meters_enabled,omitempty"` TrialStartedAt *int64 `json:"trial_started_at,omitempty"` TrialEndsAt *int64 `json:"trial_ends_at,omitempty"` + + // ProviderLicense carries the Pulse-signed provider MSP license whose + // entitlement_signing_public_key claim binds the key this lease was + // signed with. Release builds verify the chain (embedded Pulse root → + // provider license → lease signature), so provider-hosted control + // planes can mint verifiable leases for the client runtimes they + // operate without holding Pulse's signing key. + ProviderLicense string `json:"provider_license,omitempty"` + jwt.RegisteredClaims } @@ -135,6 +144,7 @@ func parseEntitlementLeaseToken(token string, publicKey ed25519.PublicKey, expec if skipTimeValidation { parseOpts = append(parseOpts, jwt.WithoutClaimsValidation()) } + chained := false parsed, err := jwt.ParseWithClaims( token, claims, @@ -142,6 +152,21 @@ func parseEntitlementLeaseToken(token string, publicKey ed25519.PublicKey, expec if t.Method.Alg() != jwt.SigningMethodEdDSA.Alg() { return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg()) } + // A lease carrying a provider license selects the provider's + // bound signing key instead of the direct trust root. The + // provider license itself must verify against the embedded + // Pulse root before its bound key is trusted, so the chain + // never widens beyond Pulse-issued MSP licenses. + if leaseClaims, ok := t.Claims.(*EntitlementLeaseClaims); ok { + if providerLicense := strings.TrimSpace(leaseClaims.ProviderLicense); providerLicense != "" { + providerKey, err := providerLeaseSigningKeyFromLicense(providerLicense) + if err != nil { + return nil, err + } + chained = true + return providerKey, nil + } + } return publicKey, nil }, parseOpts..., @@ -152,6 +177,9 @@ func parseEntitlementLeaseToken(token string, publicKey ed25519.PublicKey, expec if !parsed.Valid { return nil, errors.New("entitlement lease token is invalid") } + if chained { + capProviderChainedLeaseClaims(claims) + } claims.OrgID = strings.TrimSpace(claims.OrgID) if claims.OrgID == "" { @@ -169,6 +197,78 @@ func parseEntitlementLeaseToken(token string, publicKey ed25519.PublicKey, expec return claims, nil } +// providerLeaseSigningKeyFromLicense validates a Pulse-signed provider MSP +// license against the runtime license trust root and returns the entitlement +// lease signing key it binds. Every failure is terminal for the lease: an +// unverifiable provider license must never fall back to the direct root, +// or a forged chain would downgrade into a confusing signature error. +func providerLeaseSigningKeyFromLicense(licenseKey string) (ed25519.PublicKey, error) { + license, err := ValidateLicense(licenseKey) + if err != nil { + return nil, fmt.Errorf("provider license in entitlement lease is invalid: %w", err) + } + if license == nil { + return nil, errors.New("provider license in entitlement lease is invalid") + } + if license.Claims.Tier != TierMSP { + return nil, fmt.Errorf("provider license tier %q cannot bind an entitlement lease signing key", license.Claims.Tier) + } + encoded := strings.TrimSpace(license.Claims.EntitlementSigningPublicKey) + if encoded == "" { + return nil, errors.New("provider license does not bind an entitlement lease signing key") + } + key, err := DecodePublicKey(encoded) + if err != nil { + return nil, fmt.Errorf("provider license entitlement signing key is malformed: %w", err) + } + return key, nil +} + +// ProviderChainedLeaseCapabilities returns the capability ceiling for +// entitlement leases verified through a provider MSP license chain: the MSP +// tier set plus white_label (branded per-client reports are the hosted MSP +// packaging of that feature), minus capabilities backed by Pulse-operated +// services (relay, mobile app pairing, push routing). A provider-hosted +// deployment cannot grant those — client runtimes holding them would loop +// doomed registrations against Pulse's relay with credentials it can never +// verify. +func ProviderChainedLeaseCapabilities() []string { + excluded := map[string]struct{}{ + FeatureRelay: {}, + FeatureMobileApp: {}, + FeaturePushNotifications: {}, + } + capabilities := make([]string, 0) + for _, capability := range DeriveCapabilitiesFromTier(TierMSP, []string{FeatureWhiteLabel}) { + if _, drop := excluded[capability]; drop { + continue + } + capabilities = append(capabilities, capability) + } + return capabilities +} + +// capProviderChainedLeaseClaims bounds a chain-verified lease to +// ProviderChainedLeaseCapabilities. A provider key can therefore never mint +// enterprise-only, Pulse-service-backed, or future capabilities, regardless +// of what the lease claims. +func capProviderChainedLeaseClaims(claims *EntitlementLeaseClaims) { + if claims == nil { + return + } + allowed := make(map[string]struct{}) + for _, capability := range ProviderChainedLeaseCapabilities() { + allowed[capability] = struct{}{} + } + kept := make([]string, 0, len(claims.Capabilities)) + for _, capability := range claims.Capabilities { + if _, ok := allowed[capability]; ok { + kept = append(kept, capability) + } + } + claims.Capabilities = kept +} + // ResolveEntitlementLeaseBillingState applies a signed hosted entitlement lease // to a locally cached billing state. Invalid or expired leases fail closed. func ResolveEntitlementLeaseBillingState(state BillingState, expectedInstanceHost string, now time.Time) BillingState { diff --git a/pkg/licensing/models.go b/pkg/licensing/models.go index 364d0a11a..b724416a8 100644 --- a/pkg/licensing/models.go +++ b/pkg/licensing/models.go @@ -36,6 +36,14 @@ type Claims struct { PlanVersion string `json:"plan_version,omitempty"` SubState SubscriptionState `json:"subscription_state,omitempty"` + // EntitlementSigningPublicKey binds a provider-operated control plane's + // entitlement lease signing key (base64 Ed25519 public key) to this + // license. Hosted entitlement leases signed by the bound key chain back + // to the embedded Pulse trust root through this license, so release + // builds can verify provider-minted leases without trusting environment + // wiring. Only meaningful on MSP-tier licenses. + EntitlementSigningPublicKey string `json:"entitlement_signing_public_key,omitempty"` + // CoreMonitoringUncapped is a local runtime marker used when a self-hosted // activation/grant should remain uncapped even if stale legacy limit fields // are still present in the persisted claims. It is intentionally excluded diff --git a/pkg/licensing/provider_chain_test.go b/pkg/licensing/provider_chain_test.go new file mode 100644 index 000000000..4b9498d92 --- /dev/null +++ b/pkg/licensing/provider_chain_test.go @@ -0,0 +1,199 @@ +package licensing + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" +) + +// mintTestProviderMSPLicense signs an MSP license with the shared test root +// key, binding the given lease signing public key. setupTestPublicKey must be +// installed for ValidateLicense to accept it. +func mintTestProviderMSPLicense(t *testing.T, tier Tier, leaseSigningPublicKey ed25519.PublicKey, expiresIn time.Duration) string { + t.Helper() + testKeyPairInit() + + claims := Claims{ + LicenseID: "lic_provider_chain_test", + Email: "provider@example-msp.com", + Tier: tier, + IssuedAt: time.Now().Unix(), + PlanVersion: "msp_starter", + } + if expiresIn != 0 { + claims.ExpiresAt = time.Now().Add(expiresIn).Unix() + } + if len(leaseSigningPublicKey) > 0 { + claims.EntitlementSigningPublicKey = base64.StdEncoding.EncodeToString(leaseSigningPublicKey) + } + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal provider license claims: %v", err) + } + return signTestJWT(t, payload, testPrivateKey) +} + +func signTestProviderLease(t *testing.T, signingKey ed25519.PrivateKey, providerLicense string, capabilities []string) string { + t.Helper() + + claims := EntitlementLeaseClaims{ + OrgID: "default", + InstanceHost: "t-acme.pulse.example-msp.com", + PlanVersion: "msp_starter", + SubscriptionState: SubStateActive, + Capabilities: capabilities, + ProviderLicense: providerLicense, + } + token, err := SignEntitlementLeaseToken(signingKey, claims) + if err != nil { + t.Fatalf("sign provider lease: %v", err) + } + return token +} + +func TestEntitlementLeaseProviderChainVerifies(t *testing.T) { + setupTestPublicKey(t) + + providerPub, providerPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate provider key pair: %v", err) + } + license := mintTestProviderMSPLicense(t, TierMSP, providerPub, 30*24*time.Hour) + token := signTestProviderLease(t, providerPriv, license, []string{FeatureWhiteLabel, FeatureMultiTenant}) + + claims, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()) + if err != nil { + t.Fatalf("VerifyEntitlementLeaseToken() error = %v, want chain-verified lease", err) + } + got := strings.Join(claims.Capabilities, ",") + if !strings.Contains(got, FeatureWhiteLabel) { + t.Fatalf("chain-verified lease lost white_label: capabilities = %q", got) + } +} + +func TestEntitlementLeaseProviderChainCapsCapabilities(t *testing.T) { + setupTestPublicKey(t) + + providerPub, providerPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate provider key pair: %v", err) + } + license := mintTestProviderMSPLicense(t, TierMSP, providerPub, 30*24*time.Hour) + // multi_user is enterprise-only and relay is Pulse-service-backed; a + // provider-minted lease must not be able to grant either even though + // the lease signature verifies. + token := signTestProviderLease(t, providerPriv, license, []string{FeatureWhiteLabel, FeatureMultiUser, FeatureRelay}) + + claims, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()) + if err != nil { + t.Fatalf("VerifyEntitlementLeaseToken() error = %v", err) + } + for _, capability := range claims.Capabilities { + switch capability { + case FeatureMultiUser: + t.Fatalf("chained lease escaped the MSP capability ceiling: %v", claims.Capabilities) + case FeatureRelay, FeatureMobileApp, FeaturePushNotifications: + t.Fatalf("chained lease must not grant Pulse-service-backed capability %q: %v", capability, claims.Capabilities) + } + } + if got := strings.Join(claims.Capabilities, ","); !strings.Contains(got, FeatureWhiteLabel) { + t.Fatalf("capability cap removed white_label, which MSP leases legitimately carry: %q", got) + } +} + +func TestEntitlementLeaseProviderChainRejectsWrongSigner(t *testing.T) { + setupTestPublicKey(t) + + providerPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate provider key pair: %v", err) + } + _, otherPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate other key pair: %v", err) + } + license := mintTestProviderMSPLicense(t, TierMSP, providerPub, 30*24*time.Hour) + token := signTestProviderLease(t, otherPriv, license, []string{FeatureWhiteLabel}) + + if _, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()); err == nil { + t.Fatal("lease signed by a key the provider license does not bind must be rejected") + } +} + +func TestEntitlementLeaseProviderChainRejectsNonMSPLicense(t *testing.T) { + setupTestPublicKey(t) + + providerPub, providerPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate provider key pair: %v", err) + } + license := mintTestProviderMSPLicense(t, TierPro, providerPub, 30*24*time.Hour) + token := signTestProviderLease(t, providerPriv, license, []string{FeatureWhiteLabel}) + + if _, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()); err == nil { + t.Fatal("non-MSP license must not bind an entitlement lease signing key") + } +} + +func TestEntitlementLeaseProviderChainRejectsUnboundLicense(t *testing.T) { + setupTestPublicKey(t) + + _, providerPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate provider key pair: %v", err) + } + license := mintTestProviderMSPLicense(t, TierMSP, nil, 30*24*time.Hour) + token := signTestProviderLease(t, providerPriv, license, []string{FeatureWhiteLabel}) + + if _, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()); err == nil { + t.Fatal("license without an entitlement signing key binding must not chain") + } +} + +func TestEntitlementLeaseProviderChainRejectsForgedLicense(t *testing.T) { + setupTestPublicKey(t) + + providerPub, providerPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate provider key pair: %v", err) + } + // License signed by the provider's own key instead of the Pulse root: a + // self-issued chain must die at license validation. + 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, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal forged license claims: %v", err) + } + forgedLicense := signTestJWT(t, payload, providerPriv) + token := signTestProviderLease(t, providerPriv, forgedLicense, []string{FeatureWhiteLabel}) + + if _, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()); err == nil { + t.Fatal("a provider license not signed by the Pulse root must not chain") + } +} + +func TestEntitlementLeaseWithoutProviderLicenseUsesDirectRoot(t *testing.T) { + setupTestPublicKey(t) + + token := signTestProviderLease(t, testPrivateKey, "", []string{FeatureWhiteLabel}) + claims, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()) + if err != nil { + t.Fatalf("direct-root lease verification regressed: %v", err) + } + // Direct-root leases are not capability-capped; the signer is Pulse. + if got := strings.Join(claims.Capabilities, ","); !strings.Contains(got, FeatureWhiteLabel) { + t.Fatalf("direct-root lease capabilities = %q", got) + } +}