Require license-backed provider MSP proof

This commit is contained in:
rcourtman 2026-06-02 13:22:15 +01:00
parent 3630ab1867
commit c9c415d7a6
9 changed files with 202 additions and 7 deletions

View file

@ -66,6 +66,8 @@ func printProviderMSPBootstrapResult(result *cloudcp.ProviderMSPBootstrapResult)
fmt.Printf("owner_email=%s\n", result.OwnerEmail)
fmt.Printf("plan_version=%s\n", result.PlanVersion)
fmt.Printf("plan_source=%s\n", result.PlanSource)
fmt.Printf("license_id=%s\n", result.LicenseID)
fmt.Printf("license_email=%s\n", result.LicenseEmail)
fmt.Printf("workspace_limit=%d\n", result.WorkspaceLimit)
if result.MagicLinkURL != "" {
fmt.Printf("portal_magic_link=%s\n", result.MagicLinkURL)

View file

@ -41,6 +41,7 @@ type providerMSPProofOptions struct {
InstallType string
TargetPath string
Cleanup bool
AllowEnvPlan bool
AllowNonProofWorkspaceName bool
}
@ -78,6 +79,8 @@ type providerMSPProofReport struct {
OwnerEmail string
PlanVersion string
PlanSource string
LicenseID string
LicenseEmail string
WorkspaceLimit int
WorkspaceCount int
Workspaces []providerMSPProofWorkspace
@ -124,6 +127,7 @@ func newProviderMSPProofCmd() *cobra.Command {
cmd.Flags().StringVar(&opts.InstallType, "install-type", opts.InstallType, "Hosted tenant install command type: pve or pbs")
cmd.Flags().StringVar(&opts.TargetPath, "target-path", opts.TargetPath, "Tenant-local target path to verify during handoff exchange")
cmd.Flags().BoolVar(&opts.Cleanup, "cleanup", false, "Delete proof workspaces after the proof completes")
cmd.Flags().BoolVar(&opts.AllowEnvPlan, "allow-env-plan", false, "Allow CP_PROVIDER_MSP_PLAN_VERSION fallback instead of a signed provider MSP license file for local development")
cmd.Flags().BoolVar(&opts.AllowNonProofWorkspaceName, "allow-non-proof-workspace-name", false, "Allow proof workspace names without proof/canary/rehearsal markers")
_ = cmd.MarkFlagRequired("account-name")
_ = cmd.MarkFlagRequired("owner-email")
@ -213,6 +217,9 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts
if err != nil {
return nil, err
}
if !opts.AllowEnvPlan && strings.TrimSpace(rt.cfg.ProviderMSPPlanSource) != cloudcp.ProviderMSPPlanSourceLicenseFile {
return nil, fmt.Errorf("provider MSP proof requires %s plan source; rerun with --allow-env-plan only for local development", cloudcp.ProviderMSPPlanSourceLicenseFile)
}
bootstrap, err := cloudcp.BootstrapProviderMSP(ctx, rt.cfg, cloudcp.ProviderMSPBootstrapOptions{
AccountName: opts.AccountName,
@ -237,6 +244,8 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts
OwnerEmail: bootstrap.OwnerEmail,
PlanVersion: bootstrap.PlanVersion,
PlanSource: bootstrap.PlanSource,
LicenseID: bootstrap.LicenseID,
LicenseEmail: bootstrap.LicenseEmail,
WorkspaceLimit: bootstrap.WorkspaceLimit,
RuntimeContainerVerified: true,
HandoffExchangeVerified: true,
@ -792,6 +801,8 @@ func printProviderMSPProofReport(report *providerMSPProofReport) {
fmt.Printf("owner_email=%s\n", report.OwnerEmail)
fmt.Printf("plan_version=%s\n", report.PlanVersion)
fmt.Printf("plan_source=%s\n", report.PlanSource)
fmt.Printf("license_id=%s\n", report.LicenseID)
fmt.Printf("license_email=%s\n", report.LicenseEmail)
fmt.Printf("workspace_limit=%d\n", report.WorkspaceLimit)
fmt.Printf("workspace_count=%d\n", report.WorkspaceCount)
fmt.Printf("dockerless_provisioning=%t\n", report.DockerlessProvisioning)

View file

@ -5,10 +5,14 @@ import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
func TestProviderMSPCommandExposesProof(t *testing.T) {
@ -35,6 +39,32 @@ func TestNormalizeProviderMSPProofOptionsRequiresIsolationWorkspaceCount(t *test
}
}
func TestProviderMSPProofRequiresLicenseBackedPlanSourceByDefault(t *testing.T) {
cfg := testProviderMSPProofConfig(t)
cfg.ProviderMSPPlanSource = cloudcp.ProviderMSPPlanSourceEnvFallback
rt, err := newProviderMSPProofRuntimeFromConfig(cfg)
if err != nil {
t.Fatalf("newProviderMSPProofRuntimeFromConfig: %v", err)
}
defer rt.close()
_, err = rt.runProviderMSPProof(context.Background(), providerMSPProofOptions{
AccountName: "Acme Provider",
OwnerEmail: "owner@example.com",
WorkspacePrefix: "Provider MSP Proof",
WorkspaceCount: 2,
InstallType: "pve",
TargetPath: "/settings/infrastructure?add=linux-host",
})
if err == nil {
t.Fatal("expected provider MSP proof to reject environment-fallback plan source")
}
if !strings.Contains(err.Error(), cloudcp.ProviderMSPPlanSourceLicenseFile) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///tmp/pulse-provider-msp-proof-missing-docker.sock")
t.Setenv("DOCKER_TLS_VERIFY", "")
@ -68,6 +98,9 @@ func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing
if report.PlanVersion != "msp_growth" || report.WorkspaceLimit != 15 {
t.Fatalf("plan proof = %q limit=%d, want msp_growth limit 15", report.PlanVersion, report.WorkspaceLimit)
}
if report.PlanSource != cloudcp.ProviderMSPPlanSourceLicenseFile {
t.Fatalf("PlanSource = %q, want %q", report.PlanSource, cloudcp.ProviderMSPPlanSourceLicenseFile)
}
if report.WorkspaceCount != 2 || len(report.Workspaces) != 2 {
t.Fatalf("WorkspaceCount = %d len=%d, want 2", report.WorkspaceCount, len(report.Workspaces))
}
@ -129,6 +162,60 @@ func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing
}
}
func TestProviderMSPProofLoadsSignedLicenseFilePlan(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///tmp/pulse-provider-msp-proof-missing-docker.sock")
t.Setenv("DOCKER_TLS_VERIFY", "")
t.Setenv("DOCKER_CERT_PATH", "")
dataDir := t.TempDir()
licenseFile := writeProviderMSPProofLicenseForTest(t, "lic_provider_msp_proof", "provider@example.com", "msp_growth")
setProviderMSPProofLoadConfigEnv(t, dataDir, licenseFile)
cfg, err := cloudcp.LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.ProviderMSPPlanSource != cloudcp.ProviderMSPPlanSourceLicenseFile {
t.Fatalf("ProviderMSPPlanSource = %q, want %q", cfg.ProviderMSPPlanSource, cloudcp.ProviderMSPPlanSourceLicenseFile)
}
if cfg.ProviderMSPPlanVersion != "msp_growth" {
t.Fatalf("ProviderMSPPlanVersion = %q, want msp_growth", cfg.ProviderMSPPlanVersion)
}
rt, err := newProviderMSPProofRuntimeFromConfig(cfg)
if err != nil {
t.Fatalf("newProviderMSPProofRuntimeFromConfig: %v", err)
}
defer rt.close()
report, err := rt.runProviderMSPProof(context.Background(), providerMSPProofOptions{
AccountName: "Acme Provider",
OwnerEmail: "owner@example.com",
WorkspacePrefix: "Provider MSP Proof",
WorkspaceCount: 2,
InstallType: "pve",
TargetPath: "/settings/infrastructure?add=linux-host",
})
if err != nil {
t.Fatalf("runProviderMSPProof: %v", err)
}
if report.PlanVersion != "msp_growth" || report.WorkspaceLimit != 15 {
t.Fatalf("plan proof = %q limit=%d, want msp_growth limit 15", report.PlanVersion, report.WorkspaceLimit)
}
if report.PlanSource != cloudcp.ProviderMSPPlanSourceLicenseFile {
t.Fatalf("PlanSource = %q, want %q", report.PlanSource, cloudcp.ProviderMSPPlanSourceLicenseFile)
}
if report.LicenseID != "lic_provider_msp_proof" {
t.Fatalf("LicenseID = %q, want lic_provider_msp_proof", report.LicenseID)
}
if report.LicenseEmail != "provider@example.com" {
t.Fatalf("LicenseEmail = %q, want provider@example.com", report.LicenseEmail)
}
if !report.AgentReportIngestVerified || !report.InstallTokenBoundaryOK || !report.HandoffExchangeVerified {
t.Fatalf("provider MSP proof did not complete core runtime checks: %#v", report)
}
}
func testProviderMSPProofConfig(t *testing.T) *cloudcp.CPConfig {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
@ -150,8 +237,68 @@ func testProviderMSPProofConfig(t *testing.T) *cloudcp.CPConfig {
StorageGuardrailsEnabled: false,
ProviderMSPPlanVersion: "msp_growth",
ProviderMSPPlanSource: cloudcp.ProviderMSPPlanSourceLicenseFile,
ProviderMSPLicenseID: "lic_provider_msp_test",
ProviderMSPLicenseEmail: "provider@example.com",
TrialActivationPrivateKey: base64.StdEncoding.EncodeToString(privateKey),
TrialActivationPublicKey: base64.StdEncoding.EncodeToString(publicKey),
EmailFrom: "noreply@example.com",
}
}
func setProviderMSPProofLoadConfigEnv(t *testing.T, dataDir, licenseFile string) {
t.Helper()
t.Setenv("CP_ADMIN_KEY", "test-key")
t.Setenv("CP_BASE_URL", "https://msp.example.com")
t.Setenv("CP_DATA_DIR", dataDir)
t.Setenv("CP_ENV", "development")
t.Setenv("CP_CONTROL_PLANE_MODE", string(cloudcp.ControlPlaneModeProviderHostedMSP))
t.Setenv("CP_REQUIRE_EMAIL_PROVIDER", "false")
t.Setenv("STRIPE_WEBHOOK_SECRET", "")
t.Setenv("STRIPE_API_KEY", "")
t.Setenv("CP_PUBLIC_CLOUD_SIGNUP_ENABLED", "false")
t.Setenv("CP_MSP_STARTER_PRICE_ID", "")
t.Setenv("CP_MSP_GROWTH_PRICE_ID", "")
t.Setenv("CP_MSP_SCALE_PRICE_ID", "")
t.Setenv("CP_PROVIDER_MSP_PLAN_VERSION", "msp_starter")
t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", licenseFile)
t.Setenv("CP_PULSE_IMAGE", "pulse:test")
t.Setenv("CP_DOCKER_NETWORK", "bridge")
t.Setenv("CP_ALLOW_DOCKERLESS_PROVISIONING", "true")
t.Setenv("CP_STORAGE_GUARDRAILS_ENABLED", "false")
t.Setenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY", "A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=")
}
func writeProviderMSPProofLicenseForTest(t *testing.T, licenseID, email, planVersion string) string {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
t.Setenv("PULSE_LICENSE_PUBLIC_KEY", base64.StdEncoding.EncodeToString(publicKey))
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
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,
}
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
payloadBytes, err := json.Marshal(claims)
if err != nil {
t.Fatalf("Marshal claims: %v", err)
}
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
signedData := []byte(header + "." + payload)
signature := base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, signedData))
licenseKey := header + "." + payload + "." + signature
path := t.TempDir() + "/provider-msp-license.jwt"
if err := os.WriteFile(path, []byte(licenseKey+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return path
}

View file

@ -36,3 +36,15 @@ PULSE_EMAIL_REPLY_TO=support@example.com
# docker compose run --rm control-plane provider-msp bootstrap \
# --account-name "Example MSP" \
# --owner-email owner@example.com
#
# Install readiness check:
# docker compose run --rm control-plane provider-msp preflight
#
# End-to-end provider proof. This creates two proof client workspaces, generates
# client-bound agent install tokens, verifies tenant-local agent report ingest,
# and exercises the portal handoff path. Keep --cleanup once you have captured
# the output you need.
# docker compose run --rm control-plane provider-msp proof \
# --account-name "Example MSP" \
# --owner-email owner@example.com \
# --cleanup

View file

@ -20,6 +20,12 @@ agreement, the Pulse Cloud control plane, provider-hosted MSP account
bootstrap/licensing, hosted tenant lifecycle, and cloud-specific enforcement
rules.
Provider-hosted MSP bootstrap output is part of the cloud-paid operator
contract. It must carry the resolved MSP plan version, plan source, workspace
limit, and validated provider MSP license id/email so installability proof can
distinguish signed license-backed activation from local environment fallback
configuration.
## Canonical Files
1. `pkg/licensing/features.go`

View file

@ -127,7 +127,9 @@ surfaces.
onboarding path through workspace creation, client-bound install token
generation, tenant-local unified-agent report ingest, handoff exchange, and
duplicate-hostname isolation before provider-hosted MSP installability is
treated as proven.
treated as proven. The proof is license-backed by default: `license_file`
must be the resolved provider MSP plan source unless the operator explicitly
opts into the local-development `--allow-env-plan` escape hatch.
5. `internal/cloudcp/tenant_runtime_rollout.go` shared with `cloud-paid`: hosted tenant runtime rollout is both a Pulse Cloud runtime contract boundary and a deployment-installability release-rollout boundary.
6. `scripts/install.ps1` shared with `agent-lifecycle`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
It must expose a non-mutating preflight for the exact Windows agent

View file

@ -30,6 +30,8 @@ type ProviderMSPBootstrapResult struct {
OwnerEmail string
PlanVersion string
PlanSource string
LicenseID string
LicenseEmail string
WorkspaceLimit int
MagicLinkURL string
}
@ -107,6 +109,8 @@ func BootstrapProviderMSP(ctx context.Context, cfg *CPConfig, opts ProviderMSPBo
OwnerEmail: ownerEmail,
PlanVersion: cfg.ProviderMSPPlanVersion,
PlanSource: providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource),
LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID),
LicenseEmail: strings.ToLower(strings.TrimSpace(cfg.ProviderMSPLicenseEmail)),
WorkspaceLimit: workspaceLimit,
MagicLinkURL: magicLinkURL,
}, nil

View file

@ -34,6 +34,12 @@ func TestBootstrapProviderMSPCreatesOwnerAccountAndMagicLink(t *testing.T) {
if result.PlanSource != ProviderMSPPlanSourceLicenseFile {
t.Fatalf("PlanSource = %q, want %q", result.PlanSource, ProviderMSPPlanSourceLicenseFile)
}
if result.LicenseID != "lic_provider_msp_test" {
t.Fatalf("LicenseID = %q, want lic_provider_msp_test", result.LicenseID)
}
if result.LicenseEmail != "provider@example.com" {
t.Fatalf("LicenseEmail = %q, want provider@example.com", result.LicenseEmail)
}
if result.WorkspaceLimit != 15 {
t.Fatalf("WorkspaceLimit = %d, want 15", result.WorkspaceLimit)
}
@ -114,11 +120,13 @@ func TestBootstrapProviderMSPRejectsPulseHostedMode(t *testing.T) {
func testProviderMSPBootstrapConfig(t *testing.T) *CPConfig {
t.Helper()
return &CPConfig{
DataDir: t.TempDir(),
Environment: "production",
ControlPlaneMode: ControlPlaneModeProviderHostedMSP,
BaseURL: "https://msp.example.com",
ProviderMSPPlanVersion: "msp_growth",
ProviderMSPPlanSource: ProviderMSPPlanSourceLicenseFile,
DataDir: t.TempDir(),
Environment: "production",
ControlPlaneMode: ControlPlaneModeProviderHostedMSP,
BaseURL: "https://msp.example.com",
ProviderMSPPlanVersion: "msp_growth",
ProviderMSPPlanSource: ProviderMSPPlanSourceLicenseFile,
ProviderMSPLicenseID: "lic_provider_msp_test",
ProviderMSPLicenseEmail: "Provider@Example.com",
}
}

View file

@ -47,8 +47,11 @@ func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) {
"CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt",
"CP_TRIAL_ACTIVATION_PRIVATE_KEY=",
"docker compose run --rm control-plane provider-msp bootstrap",
"docker compose run --rm control-plane provider-msp preflight",
"docker compose run --rm control-plane provider-msp proof",
"--account-name",
"--owner-email",
"--cleanup",
)
assertNotContainsAny(t, text,
"STRIPE_",