mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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.
This commit is contained in:
parent
edd7f001ab
commit
d25b99cbcc
17 changed files with 688 additions and 33 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, ","))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
199
pkg/licensing/provider_chain_test.go
Normal file
199
pkg/licensing/provider_chain_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue