Add provider MSP proof command

Adds a provider-hosted MSP proof command that exercises provider bootstrap, workspace creation, hosted tenant install-token generation, handoff exchange, setup-facts visibility, and cross-tenant token isolation.
This commit is contained in:
rcourtman 2026-06-02 12:45:48 +01:00
parent 5134e36c28
commit 3f20c85e86
14 changed files with 1276 additions and 149 deletions

View file

@ -13,6 +13,7 @@ func newProviderMSPCmd() *cobra.Command {
Short: "Operate a provider-hosted MSP control plane",
}
cmd.AddCommand(newProviderMSPBootstrapCmd())
cmd.AddCommand(newProviderMSPProofCmd())
return cmd
}

View file

@ -0,0 +1,691 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/api"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/account"
cpDocker "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/docker"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/entitlements"
cphandoff "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/handoff"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/portal"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
cpstripe "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/stripe"
runtimeconfig "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/spf13/cobra"
)
const defaultProviderMSPProofWorkspacePrefix = "Provider MSP Proof"
var handoffTokenInputPattern = regexp.MustCompile(`name="token"\s+value="([^"]+)"`)
type providerMSPProofOptions struct {
AccountName string
OwnerEmail string
WorkspacePrefix string
WorkspaceCount int
InstallType string
TargetPath string
Cleanup bool
AllowNonProofWorkspaceName bool
}
type providerMSPProofRuntime struct {
cfg *cloudcp.CPConfig
registry *registry.TenantRegistry
docker *cpDocker.Manager
provisioner *cpstripe.Provisioner
}
type providerMSPProofWorkspace struct {
TenantID string
DisplayName string
State string
PlanVersion string
ContainerID string
PublicURL string
InstallType string
InstallToken string
InstallTokenID string
InstallCommandGenerated bool
AgentTokenAuthVerified bool
SetupFactsTokenUseVisible bool
HandoffExchangeVerified bool
HandoffTargetPath string
}
type providerMSPProofReport struct {
AccountID string
AccountName string
OwnerUserID string
OwnerEmail string
PlanVersion string
PlanSource string
WorkspaceLimit int
WorkspaceCount int
Workspaces []providerMSPProofWorkspace
DockerlessProvisioning bool
RuntimeContainerVerified bool
HandoffExchangeVerified bool
InstallTokenBoundaryOK bool
SetupFactsTokenUseVisible bool
AgentReportIngestVerified bool
Cleanup bool
}
func newProviderMSPProofCmd() *cobra.Command {
opts := providerMSPProofOptions{
WorkspacePrefix: defaultProviderMSPProofWorkspacePrefix,
WorkspaceCount: 2,
InstallType: "pve",
TargetPath: "/settings/infrastructure?add=linux-host",
}
cmd := &cobra.Command{
Use: "proof",
Short: "Prove the provider-hosted MSP workspace and handoff path",
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := newProviderMSPProofRuntime(cmd.Context())
if err != nil {
return err
}
defer rt.close()
report, err := rt.runProviderMSPProof(cmd.Context(), opts)
if err != nil {
return err
}
printProviderMSPProofReport(report)
return nil
},
}
cmd.Flags().StringVar(&opts.AccountName, "account-name", "", "Provider MSP account display name")
cmd.Flags().StringVar(&opts.OwnerEmail, "owner-email", "", "Provider owner email address")
cmd.Flags().StringVar(&opts.WorkspacePrefix, "workspace-prefix", opts.WorkspacePrefix, "Proof workspace display-name prefix")
cmd.Flags().IntVar(&opts.WorkspaceCount, "workspace-count", opts.WorkspaceCount, "Number of proof client workspaces to create; minimum 2")
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.AllowNonProofWorkspaceName, "allow-non-proof-workspace-name", false, "Allow proof workspace names without proof/canary/rehearsal markers")
_ = cmd.MarkFlagRequired("account-name")
_ = cmd.MarkFlagRequired("owner-email")
return cmd
}
func newProviderMSPProofRuntime(ctx context.Context) (*providerMSPProofRuntime, error) {
cfg, err := cloudcp.LoadConfig()
if err != nil {
return nil, fmt.Errorf("load control plane config: %w", err)
}
return newProviderMSPProofRuntimeFromConfig(cfg)
}
func newProviderMSPProofRuntimeFromConfig(cfg *cloudcp.CPConfig) (*providerMSPProofRuntime, error) {
if cfg == nil {
return nil, fmt.Errorf("control plane config is required")
}
if !cfg.IsProviderHostedMSP() {
return nil, fmt.Errorf("provider MSP proof requires CP_CONTROL_PLANE_MODE=%s", cloudcp.ControlPlaneModeProviderHostedMSP)
}
if err := os.MkdirAll(cfg.TenantsDir(), 0o755); err != nil {
return nil, fmt.Errorf("create tenants dir: %w", err)
}
if err := os.MkdirAll(cfg.ControlPlaneDir(), 0o755); err != nil {
return nil, fmt.Errorf("create control-plane dir: %w", err)
}
reg, err := registry.NewTenantRegistry(cfg.ControlPlaneDir())
if err != nil {
return nil, fmt.Errorf("open tenant registry: %w", err)
}
var dockerMgr *cpDocker.Manager
dockerMgr, err = cpDocker.NewManager(cpDocker.ManagerConfig{
Image: cfg.PulseImage,
Network: cfg.DockerNetwork,
BaseDomain: providerMSPProofBaseDomainFromURL(cfg.BaseURL),
TrialActivationPublicKey: cfg.TrialActivationPublicKey,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
MemoryLimit: cfg.TenantMemoryLimit,
CPUShares: cfg.TenantCPUShares,
TenantLogMaxSize: cfg.TenantLogMaxSize,
TenantLogMaxFile: cfg.TenantLogMaxFile,
})
if err != nil {
if !cfg.AllowDockerlessProvisioning {
reg.Close()
return nil, fmt.Errorf("create docker manager: %w", err)
}
dockerMgr = nil
}
hostedEntitlements := entitlements.NewService(reg, cfg.BaseURL, cfg.TrialActivationPrivateKey)
provisioner := cpstripe.NewProvisioner(
reg,
cfg.TenantsDir(),
dockerMgr,
nil,
cfg.BaseURL,
nil,
cfg.EmailFrom,
cfg.AllowDockerlessProvisioning,
cpstripe.WithHostedEntitlementService(hostedEntitlements),
cpstripe.WithTrialActivationPrivateKey(cfg.TrialActivationPrivateKey),
cpstripe.WithAdmissionCheck(func(checkCtx context.Context) error {
return cloudcp.EnforceStorageAdmission(checkCtx, cfg, dockerMgr)
}),
cpstripe.WithDefaultMSPPlanVersion(providerMSPProofPlanVersion(cfg)),
)
return &providerMSPProofRuntime{
cfg: cfg,
registry: reg,
docker: dockerMgr,
provisioner: provisioner,
}, nil
}
func (rt *providerMSPProofRuntime) close() {
if rt == nil {
return
}
if rt.docker != nil {
rt.docker.Close()
}
if rt.registry != nil {
rt.registry.Close()
}
}
func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts providerMSPProofOptions) (*providerMSPProofReport, error) {
if rt == nil || rt.cfg == nil || rt.registry == nil || rt.provisioner == nil {
return nil, fmt.Errorf("provider MSP proof runtime is not initialized")
}
opts, err := normalizeProviderMSPProofOptions(opts)
if err != nil {
return nil, err
}
bootstrap, err := cloudcp.BootstrapProviderMSP(ctx, rt.cfg, cloudcp.ProviderMSPBootstrapOptions{
AccountName: opts.AccountName,
OwnerEmail: opts.OwnerEmail,
GenerateMagicLink: false,
})
if err != nil {
return nil, fmt.Errorf("bootstrap provider MSP account: %w", err)
}
createdTenants := make([]*registry.Tenant, 0, opts.WorkspaceCount)
if opts.Cleanup {
defer func() {
_ = rt.cleanupProviderMSPProofTenants(context.Background(), createdTenants)
}()
}
report := &providerMSPProofReport{
AccountID: bootstrap.AccountID,
AccountName: bootstrap.AccountName,
OwnerUserID: bootstrap.OwnerUserID,
OwnerEmail: bootstrap.OwnerEmail,
PlanVersion: bootstrap.PlanVersion,
PlanSource: bootstrap.PlanSource,
WorkspaceLimit: bootstrap.WorkspaceLimit,
RuntimeContainerVerified: true,
HandoffExchangeVerified: true,
InstallTokenBoundaryOK: true,
SetupFactsTokenUseVisible: true,
Cleanup: opts.Cleanup,
}
for idx := 0; idx < opts.WorkspaceCount; idx++ {
displayName := fmt.Sprintf("%s %02d", opts.WorkspacePrefix, idx+1)
tenant, err := rt.createProviderMSPProofWorkspace(ctx, bootstrap.AccountID, displayName, bootstrap.OwnerEmail)
if err != nil {
return nil, fmt.Errorf("create proof workspace %d: %w", idx+1, err)
}
createdTenants = append(createdTenants, tenant)
workspace, err := rt.proveProviderMSPWorkspace(ctx, tenant, bootstrap.OwnerUserID, opts.InstallType, opts.TargetPath)
if err != nil {
return nil, fmt.Errorf("prove workspace %s: %w", tenant.ID, err)
}
if workspace.ContainerID == "" {
report.RuntimeContainerVerified = false
report.DockerlessProvisioning = true
}
if !workspace.HandoffExchangeVerified {
report.HandoffExchangeVerified = false
}
if !workspace.SetupFactsTokenUseVisible {
report.SetupFactsTokenUseVisible = false
}
report.Workspaces = append(report.Workspaces, workspace)
}
boundaryOK, err := rt.verifyProviderMSPInstallTokenIsolation(report.Workspaces)
if err != nil {
return nil, err
}
report.InstallTokenBoundaryOK = boundaryOK
report.WorkspaceCount = len(report.Workspaces)
return report, nil
}
func normalizeProviderMSPProofOptions(opts providerMSPProofOptions) (providerMSPProofOptions, error) {
opts.AccountName = strings.TrimSpace(opts.AccountName)
opts.OwnerEmail = strings.ToLower(strings.TrimSpace(opts.OwnerEmail))
opts.WorkspacePrefix = strings.TrimSpace(opts.WorkspacePrefix)
opts.InstallType = strings.ToLower(strings.TrimSpace(opts.InstallType))
opts.TargetPath = strings.TrimSpace(opts.TargetPath)
if opts.AccountName == "" {
return opts, fmt.Errorf("--account-name is required")
}
if opts.OwnerEmail == "" {
return opts, fmt.Errorf("--owner-email is required")
}
if opts.WorkspacePrefix == "" {
opts.WorkspacePrefix = defaultProviderMSPProofWorkspacePrefix
}
if opts.WorkspaceCount < 2 {
return opts, fmt.Errorf("--workspace-count must be at least 2 so tenant isolation can be proven")
}
if _, err := apiInstallTypeForProviderMSPProof(opts.InstallType); err != nil {
return opts, err
}
if !opts.AllowNonProofWorkspaceName && !containsProviderMSPProofMarker(opts.WorkspacePrefix) {
return opts, fmt.Errorf("--workspace-prefix must contain proof, canary, rehearsal, or seed")
}
if opts.TargetPath == "" {
opts.TargetPath = "/settings/infrastructure?add=linux-host"
}
if !strings.HasPrefix(opts.TargetPath, "/") || strings.HasPrefix(opts.TargetPath, "//") {
return opts, fmt.Errorf("--target-path must be a local absolute path")
}
return opts, nil
}
func apiInstallTypeForProviderMSPProof(raw string) (string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "pve", "pbs":
return strings.ToLower(strings.TrimSpace(raw)), nil
default:
return "", fmt.Errorf("--install-type must be pve or pbs")
}
}
func containsProviderMSPProofMarker(values ...string) bool {
for _, value := range values {
normalized := strings.ToLower(strings.TrimSpace(value))
for _, marker := range []string{"proof", "canary", "rehearsal", "seed"} {
if strings.Contains(normalized, marker) {
return true
}
}
}
return false
}
func (rt *providerMSPProofRuntime) createProviderMSPProofWorkspace(ctx context.Context, accountID, displayName, ownerEmail string) (*registry.Tenant, error) {
handler := account.HandleCreateTenantWithWorkspaceLimitPolicy(
rt.registry,
rt.provisioner,
account.WorkspaceLimitPolicy{
ProviderHostedMSP: true,
ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg),
},
)
mux := http.NewServeMux()
mux.Handle("/api/accounts/{account_id}/tenants", handler)
payload, err := json.Marshal(map[string]string{"display_name": displayName})
if err != nil {
return nil, fmt.Errorf("marshal create workspace request: %w", err)
}
path := "/api/accounts/" + url.PathEscape(accountID) + "/tenants"
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Email", ownerEmail)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
return nil, fmt.Errorf("workspace create status=%d body=%s", rec.Code, strings.TrimSpace(rec.Body.String()))
}
var tenant registry.Tenant
if err := json.Unmarshal(rec.Body.Bytes(), &tenant); err != nil {
return nil, fmt.Errorf("decode workspace create response: %w", err)
}
if strings.TrimSpace(tenant.ID) == "" {
return nil, fmt.Errorf("workspace create response missing tenant id")
}
return &tenant, nil
}
func (rt *providerMSPProofRuntime) proveProviderMSPWorkspace(ctx context.Context, tenant *registry.Tenant, ownerUserID, installType, targetPath string) (providerMSPProofWorkspace, error) {
if tenant == nil {
return providerMSPProofWorkspace{}, fmt.Errorf("tenant is required")
}
tenantDataDir, err := safeProviderMSPProofTenantDataDir(rt.cfg.TenantsDir(), tenant.ID)
if err != nil {
return providerMSPProofWorkspace{}, err
}
publicURL := rt.providerMSPProofTenantPublicURL(tenant.ID)
install, err := api.GenerateHostedTenantAgentInstallCommand(api.HostedTenantAgentInstallCommandOptions{
Config: &runtimeconfig.Config{
DataPath: tenantDataDir,
ConfigPath: tenantDataDir,
PublicURL: publicURL,
},
Persistence: configPersistenceForProviderMSPProof(tenantDataDir),
MultiTenant: runtimeconfig.NewMultiTenantPersistence(tenantDataDir),
HostedMode: true,
OrgID: tenant.ID,
InstallType: installType,
OwnerUserID: ownerUserID,
BaseURL: publicURL,
})
if err != nil {
return providerMSPProofWorkspace{}, fmt.Errorf("generate hosted tenant install command: %w", err)
}
if install == nil || strings.TrimSpace(install.Command) == "" || strings.TrimSpace(install.Token) == "" {
return providerMSPProofWorkspace{}, fmt.Errorf("hosted tenant install command result is incomplete")
}
tokenAuthVerified, err := markProviderMSPProofAgentTokenUsed(tenantDataDir, tenant.ID, install.Token)
if err != nil {
return providerMSPProofWorkspace{}, err
}
facts := portal.NewTenantDirWorkspaceSetupFactReader(rt.cfg.TenantsDir()).FactsForWorkspace(tenant.ID)
setupFactsVisible := facts.AgentCount != nil && *facts.AgentCount > 0 &&
facts.AgentTokenCount != nil && *facts.AgentTokenCount > 0 &&
facts.LastAgentSeenAt != nil
exchangedTargetPath, err := rt.verifyProviderMSPProofHandoff(ctx, tenant, ownerUserID, targetPath)
if err != nil {
return providerMSPProofWorkspace{}, err
}
return providerMSPProofWorkspace{
TenantID: tenant.ID,
DisplayName: tenant.DisplayName,
State: string(tenant.State),
PlanVersion: tenant.PlanVersion,
ContainerID: tenant.ContainerID,
PublicURL: publicURL,
InstallType: install.InstallType,
InstallToken: install.Token,
InstallTokenID: install.TokenID,
InstallCommandGenerated: true,
AgentTokenAuthVerified: tokenAuthVerified,
SetupFactsTokenUseVisible: setupFactsVisible,
HandoffExchangeVerified: exchangedTargetPath == targetPath,
HandoffTargetPath: exchangedTargetPath,
}, nil
}
func configPersistenceForProviderMSPProof(tenantDataDir string) *runtimeconfig.ConfigPersistence {
return runtimeconfig.NewConfigPersistence(tenantDataDir)
}
func markProviderMSPProofAgentTokenUsed(tenantDataDir, tenantID, rawToken string) (bool, error) {
persistence := configPersistenceForProviderMSPProof(tenantDataDir)
tokens, err := persistence.LoadAPITokens()
if err != nil {
return false, fmt.Errorf("load tenant api tokens: %w", err)
}
cfg := &runtimeconfig.Config{APITokens: tokens}
record, ok := cfg.ValidateAPIToken(rawToken)
if !ok || record == nil {
return false, fmt.Errorf("generated install token did not validate in tenant %s", tenantID)
}
if strings.TrimSpace(record.OrgID) != tenantID {
return false, fmt.Errorf("install token org id = %q, want %q", record.OrgID, tenantID)
}
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
return false, fmt.Errorf("persist used tenant api token: %w", err)
}
return true, nil
}
func (rt *providerMSPProofRuntime) verifyProviderMSPProofHandoff(ctx context.Context, tenant *registry.Tenant, ownerUserID, targetPath string) (string, error) {
baseDomain := providerMSPProofBaseDomainFromURL(rt.cfg.BaseURL)
if baseDomain == "" {
return "", fmt.Errorf("could not derive provider base domain from %q", rt.cfg.BaseURL)
}
mux := http.NewServeMux()
mux.Handle("/api/accounts/{account_id}/tenants/{tenant_id}/handoff", cphandoff.HandleHandoff(rt.registry, rt.cfg.TenantsDir()))
handoffURL := "https://" + baseDomain + "/api/accounts/" + url.PathEscape(tenant.AccountID) + "/tenants/" + url.PathEscape(tenant.ID) + "/handoff?target_path=" + url.QueryEscape(targetPath)
req := httptest.NewRequest(http.MethodPost, handoffURL, nil)
req = req.WithContext(ctx)
req.RemoteAddr = "127.0.0.1:12345"
req.Host = baseDomain
req.Header.Set("X-User-ID", ownerUserID)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return "", fmt.Errorf("handoff status=%d body=%s", rec.Code, strings.TrimSpace(rec.Body.String()))
}
matches := handoffTokenInputPattern.FindStringSubmatch(rec.Body.String())
if len(matches) != 2 || strings.TrimSpace(matches[1]) == "" {
return "", fmt.Errorf("handoff response did not include a token input")
}
tenantDataDir, err := safeProviderMSPProofTenantDataDir(rt.cfg.TenantsDir(), tenant.ID)
if err != nil {
return "", err
}
form := url.Values{"token": []string{matches[1]}}
exchangeURL := rt.providerMSPProofTenantPublicURL(tenant.ID) + "/api/cloud/handoff/exchange?format=json"
exchangeReq := httptest.NewRequest(http.MethodPost, exchangeURL, strings.NewReader(form.Encode()))
exchangeReq = exchangeReq.WithContext(ctx)
exchangeReq.RemoteAddr = "127.0.0.1:12345"
exchangeReq.Header.Set("Accept", "application/json")
exchangeReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
exchangeRec := httptest.NewRecorder()
api.HandleHandoffExchange(tenantDataDir).ServeHTTP(exchangeRec, exchangeReq)
if exchangeRec.Code != http.StatusOK {
return "", fmt.Errorf("handoff exchange status=%d body=%s", exchangeRec.Code, strings.TrimSpace(exchangeRec.Body.String()))
}
var payload struct {
TenantID string `json:"tenant_id"`
UserID string `json:"user_id"`
TargetPath string `json:"target_path"`
}
if err := json.Unmarshal(exchangeRec.Body.Bytes(), &payload); err != nil {
return "", fmt.Errorf("decode handoff exchange response: %w", err)
}
if payload.TenantID != tenant.ID {
return "", fmt.Errorf("handoff exchange tenant_id=%q, want %q", payload.TenantID, tenant.ID)
}
if payload.UserID != ownerUserID {
return "", fmt.Errorf("handoff exchange user_id=%q, want %q", payload.UserID, ownerUserID)
}
return payload.TargetPath, nil
}
func (rt *providerMSPProofRuntime) verifyProviderMSPInstallTokenIsolation(workspaces []providerMSPProofWorkspace) (bool, error) {
for _, workspace := range workspaces {
tenantDataDir, err := safeProviderMSPProofTenantDataDir(rt.cfg.TenantsDir(), workspace.TenantID)
if err != nil {
return false, err
}
tokens, err := configPersistenceForProviderMSPProof(tenantDataDir).LoadAPITokens()
if err != nil {
return false, fmt.Errorf("load tenant %s api tokens: %w", workspace.TenantID, err)
}
found := false
for _, token := range tokens {
if strings.TrimSpace(token.ID) == workspace.InstallTokenID {
found = true
break
}
}
if !found {
return false, fmt.Errorf("tenant %s missing proof install token id %s", workspace.TenantID, workspace.InstallTokenID)
}
}
for _, left := range workspaces {
for _, right := range workspaces {
if left.TenantID == right.TenantID {
continue
}
if left.InstallTokenID == right.InstallTokenID {
return false, fmt.Errorf("tenant %s and %s reused install token id %s", left.TenantID, right.TenantID, left.InstallTokenID)
}
rightTenantDataDir, err := safeProviderMSPProofTenantDataDir(rt.cfg.TenantsDir(), right.TenantID)
if err != nil {
return false, err
}
accepted, err := providerMSPProofTenantAcceptsToken(rightTenantDataDir, left.InstallToken)
if err != nil {
return false, err
}
if accepted {
return false, fmt.Errorf("tenant %s accepted install token minted for tenant %s", right.TenantID, left.TenantID)
}
}
}
return true, nil
}
func providerMSPProofTenantAcceptsToken(tenantDataDir, rawToken string) (bool, error) {
tokens, err := configPersistenceForProviderMSPProof(tenantDataDir).LoadAPITokens()
if err != nil {
return false, fmt.Errorf("load tenant api tokens: %w", err)
}
cfg := &runtimeconfig.Config{APITokens: tokens}
_, ok := cfg.ValidateAPIToken(rawToken)
return ok, nil
}
func (rt *providerMSPProofRuntime) cleanupProviderMSPProofTenants(ctx context.Context, tenants []*registry.Tenant) error {
var failures []string
for i := len(tenants) - 1; i >= 0; i-- {
tenant := tenants[i]
if tenant == nil {
continue
}
if err := rt.provisioner.DeprovisionWorkspaceContainer(ctx, tenant); err != nil {
failures = append(failures, fmt.Sprintf("%s deprovision: %v", tenant.ID, err))
}
tenantDataDir, err := safeProviderMSPProofTenantDataDir(rt.cfg.TenantsDir(), tenant.ID)
if err != nil {
failures = append(failures, fmt.Sprintf("%s dir: %v", tenant.ID, err))
continue
}
if err := os.RemoveAll(tenantDataDir); err != nil {
failures = append(failures, fmt.Sprintf("%s remove data: %v", tenant.ID, err))
}
if err := rt.registry.Delete(tenant.ID); err != nil {
failures = append(failures, fmt.Sprintf("%s delete registry: %v", tenant.ID, err))
}
}
if len(failures) > 0 {
return fmt.Errorf("cleanup failures: %s", strings.Join(failures, "; "))
}
return nil
}
func safeProviderMSPProofTenantDataDir(tenantsDir, tenantID string) (string, error) {
tenantsDir = strings.TrimSpace(tenantsDir)
tenantID = strings.TrimSpace(tenantID)
if tenantsDir == "" {
return "", fmt.Errorf("tenants dir is required")
}
if tenantID == "" || strings.ContainsAny(tenantID, `/\`) {
return "", fmt.Errorf("unsafe tenant id %q", tenantID)
}
cleanTenantsDir, err := filepath.Abs(tenantsDir)
if err != nil {
return "", fmt.Errorf("resolve tenants dir: %w", err)
}
candidate := filepath.Join(cleanTenantsDir, tenantID)
if !strings.HasPrefix(candidate, cleanTenantsDir+string(os.PathSeparator)) {
return "", fmt.Errorf("tenant data dir escaped tenants dir")
}
return candidate, nil
}
func providerMSPProofPlanVersion(cfg *cloudcp.CPConfig) string {
if cfg == nil || strings.TrimSpace(cfg.ProviderMSPPlanVersion) == "" {
return "msp_starter"
}
return strings.TrimSpace(cfg.ProviderMSPPlanVersion)
}
func providerMSPProofBaseDomainFromURL(baseURL string) string {
domain := strings.TrimSpace(baseURL)
for _, prefix := range []string{"https://", "http://"} {
if strings.HasPrefix(domain, prefix) {
domain = strings.TrimPrefix(domain, prefix)
break
}
}
if idx := strings.IndexAny(domain, ":/"); idx >= 0 {
domain = domain[:idx]
}
return domain
}
func (rt *providerMSPProofRuntime) providerMSPProofTenantPublicURL(tenantID string) string {
baseDomain := providerMSPProofBaseDomainFromURL(rt.cfg.BaseURL)
if baseDomain == "" {
return ""
}
return "https://" + strings.TrimSpace(tenantID) + "." + baseDomain
}
func printProviderMSPProofReport(report *providerMSPProofReport) {
if report == nil {
fmt.Println("provider_msp_control_plane_proof_ok=false")
return
}
fmt.Println("provider_msp_control_plane_proof_ok=true")
fmt.Printf("account_id=%s\n", report.AccountID)
fmt.Printf("account_name=%s\n", report.AccountName)
fmt.Printf("owner_user_id=%s\n", report.OwnerUserID)
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("workspace_limit=%d\n", report.WorkspaceLimit)
fmt.Printf("workspace_count=%d\n", report.WorkspaceCount)
fmt.Printf("dockerless_provisioning=%t\n", report.DockerlessProvisioning)
fmt.Printf("runtime_container_verified=%t\n", report.RuntimeContainerVerified)
fmt.Printf("handoff_exchange_verified=%t\n", report.HandoffExchangeVerified)
fmt.Printf("install_token_boundary_verified=%t\n", report.InstallTokenBoundaryOK)
fmt.Printf("setup_facts_token_use_visible=%t\n", report.SetupFactsTokenUseVisible)
fmt.Printf("agent_report_ingest_verified=%t\n", report.AgentReportIngestVerified)
fmt.Printf("cleanup=%t\n", report.Cleanup)
for _, workspace := range report.Workspaces {
fmt.Printf("workspace=%s display_name=%q state=%s plan_version=%s container_id=%s public_url=%s install_type=%s install_token_id=%s install_command_generated=%t agent_token_auth_verified=%t setup_facts_token_use_visible=%t handoff_exchange_verified=%t handoff_target_path=%s\n",
workspace.TenantID,
workspace.DisplayName,
workspace.State,
workspace.PlanVersion,
workspace.ContainerID,
workspace.PublicURL,
workspace.InstallType,
workspace.InstallTokenID,
workspace.InstallCommandGenerated,
workspace.AgentTokenAuthVerified,
workspace.SetupFactsTokenUseVisible,
workspace.HandoffExchangeVerified,
workspace.HandoffTargetPath,
)
}
}

View file

@ -0,0 +1,148 @@
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
)
func TestProviderMSPCommandExposesProof(t *testing.T) {
cmd := newProviderMSPCmd()
for _, child := range cmd.Commands() {
if child.Name() == "proof" {
return
}
}
t.Fatal("provider-msp proof command is not registered")
}
func TestNormalizeProviderMSPProofOptionsRequiresIsolationWorkspaceCount(t *testing.T) {
_, err := normalizeProviderMSPProofOptions(providerMSPProofOptions{
AccountName: "Acme MSP",
OwnerEmail: "owner@example.com",
WorkspacePrefix: "Provider MSP Proof",
WorkspaceCount: 1,
InstallType: "pve",
TargetPath: "/settings/infrastructure?add=linux-host",
})
if err == nil || !strings.Contains(err.Error(), "at least 2") {
t.Fatalf("expected isolation workspace-count error, got %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", "")
t.Setenv("DOCKER_CERT_PATH", "")
cfg := testProviderMSPProofConfig(t)
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: "pbs",
TargetPath: "/settings/infrastructure?add=linux-host",
})
if err != nil {
t.Fatalf("runProviderMSPProof: %v", err)
}
if report.AccountID == "" || report.OwnerUserID == "" {
t.Fatalf("bootstrap identity incomplete: %#v", report)
}
if report.OwnerEmail != "owner@example.com" {
t.Fatalf("OwnerEmail = %q, want owner@example.com", report.OwnerEmail)
}
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.WorkspaceCount != 2 || len(report.Workspaces) != 2 {
t.Fatalf("WorkspaceCount = %d len=%d, want 2", report.WorkspaceCount, len(report.Workspaces))
}
if !report.DockerlessProvisioning {
t.Fatal("expected dockerless provisioning in missing-docker test mode")
}
if report.RuntimeContainerVerified {
t.Fatal("runtime container should not be verified in dockerless test mode")
}
if !report.HandoffExchangeVerified {
t.Fatal("handoff exchange was not verified")
}
if !report.InstallTokenBoundaryOK {
t.Fatal("install token boundary was not verified")
}
if !report.SetupFactsTokenUseVisible {
t.Fatal("setup facts did not see hosted root token use")
}
if report.AgentReportIngestVerified {
t.Fatal("agent report ingest should remain explicitly unverified in this control-plane proof")
}
seenTenants := map[string]struct{}{}
for _, workspace := range report.Workspaces {
if workspace.TenantID == "" {
t.Fatalf("workspace missing tenant id: %#v", workspace)
}
if _, exists := seenTenants[workspace.TenantID]; exists {
t.Fatalf("duplicate tenant id %s", workspace.TenantID)
}
seenTenants[workspace.TenantID] = struct{}{}
if workspace.InstallType != "pbs" {
t.Fatalf("InstallType = %q, want pbs", workspace.InstallType)
}
if !workspace.InstallCommandGenerated || workspace.InstallTokenID == "" || workspace.InstallToken == "" {
t.Fatalf("workspace install proof incomplete: %#v", workspace)
}
if !strings.Contains(workspace.PublicURL, workspace.TenantID+".msp.example.com") {
t.Fatalf("PublicURL = %q, want tenant subdomain", workspace.PublicURL)
}
if !workspace.AgentTokenAuthVerified {
t.Fatalf("agent token auth not verified for %s", workspace.TenantID)
}
if !workspace.SetupFactsTokenUseVisible {
t.Fatalf("setup facts token use not visible for %s", workspace.TenantID)
}
if !workspace.HandoffExchangeVerified || workspace.HandoffTargetPath != "/settings/infrastructure?add=linux-host" {
t.Fatalf("handoff exchange proof mismatch: %#v", workspace)
}
}
}
func testProviderMSPProofConfig(t *testing.T) *cloudcp.CPConfig {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
return &cloudcp.CPConfig{
DataDir: t.TempDir(),
Environment: "development",
ControlPlaneMode: cloudcp.ControlPlaneModeProviderHostedMSP,
BaseURL: "https://msp.example.com",
PulseImage: "pulse:test",
DockerNetwork: "bridge",
TenantMemoryLimit: 512 * 1024 * 1024,
TenantCPUShares: 256,
TenantLogMaxSize: "10m",
TenantLogMaxFile: 3,
AllowDockerlessProvisioning: true,
StorageGuardrailsEnabled: false,
ProviderMSPPlanVersion: "msp_growth",
ProviderMSPPlanSource: cloudcp.ProviderMSPPlanSourceLicenseFile,
TrialActivationPrivateKey: base64.StdEncoding.EncodeToString(privateKey),
TrialActivationPublicKey: base64.StdEncoding.EncodeToString(publicKey),
EmailFrom: "noreply@example.com",
}
}

View file

@ -125,6 +125,11 @@ reverse.
behavior aligned so the Machines onboarding path does not diverge by OS.
18. `frontend-modern/src/utils/infrastructureSettingsPresentation.ts` shared with `api-contracts`: the infrastructure settings presentation helper is both an agent lifecycle control surface and an API-backed direct-node/discovery settings boundary.
19. `internal/api/agent_install_command_shared.go` shared with `api-contracts`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
19a. `internal/api/cloud_agent_install_command.go` shared with `api-contracts`, `cloud-paid`: hosted tenant agent install command generation is both an agent lifecycle enrollment surface and a provider-hosted tenant boundary.
The hosted PVE/PBS install command path must stay on the same token-file
command transport as the normal lifecycle install helpers, while failing
closed unless hosted mode is active, the target org exists, and the minted
`agent:report` token remains scoped to that tenant workspace.
20. `internal/api/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
21. `internal/api/setup_script_render.go` shared with `api-contracts`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection).
PBS setup-script auto-registration remains lifecycle-owned bootstrap

View file

@ -86,6 +86,7 @@ product API routes free of maintainer commercial analytics.
51a. `internal/api/pbs_backups.go`
52. `internal/api/config_setup_handlers.go`
52a. `internal/api/setup_script_render.go`
52b. `internal/api/cloud_agent_install_command.go`
53. `internal/api/demo_mode_commercial.go`
54. `internal/api/demo_mode_operations.go`
55. `internal/api/security_status_capabilities.go`
@ -172,6 +173,13 @@ to tenant-owned destinations such as agent installation or reporting without
turning the tenant exchange into an open redirect or moving API ownership for
agent tokens, alerts, or reports into the control plane.
Hosted tenant agent install commands are a canonical tenant-runtime API payload
boundary. The hosted route and its reusable command helper must produce the
same PVE/PBS install command shape while preserving tenant-local token
persistence, org binding, token metadata, and already-initialized tenant
monitor refresh behavior. Provider-hosted MSP control-plane proofs may exercise
that helper, but they must not create a second control-plane-only token path.
Pulse Account workspace summaries carry setup state as a backend-owned payload
contract. Browser bootstrap and `/api/portal/dashboard` workspace entries may
include `setup_status` with only `ready`, `setup_path`, `install_agents`,
@ -343,6 +351,11 @@ payload shape change when the portal presents compact client rows.
token-file and preflight transport contract: tokens are passed to the
installer as ephemeral files, and host install snippets must verify the
target Pulse URL plus exact agent binary artifact before root escalation.
32a. `internal/api/cloud_agent_install_command.go` shared with `agent-lifecycle`, `cloud-paid`: hosted tenant agent install commands are agent lifecycle enrollment transport, hosted/provider MSP tenant boundary, and canonical API payload contract.
The route and reusable helper must both mint PVE/PBS install tokens only in
hosted mode, only for an existing tenant/org, and only into that tenant
runtime's token store with the org boundary, command shape, token metadata,
and already-loaded tenant-monitor refresh behavior preserved.
33. `internal/api/ai_handler.go` shared with `ai-runtime`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
Assistant session list payloads may expose only the safe
`handoff_summary` projection needed by the browser to mark and restore a

View file

@ -51,6 +51,7 @@ rules.
27. `internal/cloudcp/entitlements/service.go`
28. `internal/cloudcp/portal/handlers.go`
29. `internal/cloudcp/portal/page.go`
29a. `internal/cloudcp/portal/setup_facts.go`
30. `internal/cloudcp/public_cloud_signup_handlers.go`
31. `internal/cloudcp/registry/models.go`
32. `internal/cloudcp/registry/registry.go`
@ -377,6 +378,11 @@ or other self-hosted uncapped continuity plans.
check remains `Review` ahead of setup counts. Local MSP onboarding previews
should be scenario-backed portal bootstrap data, not static screenshots, so
they stay grounded in the real portal shape as the bundle changes.
Hosted provider workspaces may store agent install tokens in the tenant
runtime root token store rather than the org-specific config directory.
Portal setup facts must count only root tokens whose `OrgID` or `OrgIDs`
matches the workspace tenant ID, combine them with org-local setup facts,
and never let one client's root token make another client look configured.
MSP account surfaces may call account workspaces `clients`, but that is a
customer-facing portal vocabulary choice over the same tenant/workspace
lifecycle. Mixed-account Pulse Account sessions must label account surfaces

View file

@ -27,86 +27,87 @@ surfaces.
4. `cmd/pulse-control-plane/main.go`
5. `cmd/pulse-control-plane/mobile_proof_cmd.go`
6. `cmd/pulse-control-plane/provider_msp.go`
7. `internal/cloudcp/docker/manager.go`
8. `internal/cloudcp/docker/labels.go`
9. `internal/cloudcp/tenant_runtime_rollout.go`
10. `.github/workflows/create-release.yml`
11. `.github/workflows/deploy-demo-server.yml`
12. `.github/workflows/helm-pages.yml`
13. `.github/workflows/promote-floating-tags.yml`
14. `.github/workflows/publish-docker.yml`
15. `.github/workflows/publish-helm-chart.yml`
16. `.github/workflows/release-dry-run.yml`
17. `.github/workflows/update-demo-server.yml`
18. `.github/workflows/validate-release-assets.yml`
19. `.github/workflows/install-sh-smoke.yml`
20. `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`
21. `docs/RELEASE_NOTES.md`
22. `docs/releases/`
23. `docs/UPGRADE_v6.md`
24. `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`
25. `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`
26. `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`
27. `package.json`
28. `package-lock.json`
29. `frontend-modern/package.json`
30. `frontend-modern/package-lock.json`
31. `frontend-modern/vite.config.ts`
32. `go.mod`
33. `go.sum`
34. `scripts/build-release.sh`
35. `scripts/check-workflow-dispatch-inputs.py`
36. `scripts/clean-mock-alerts.sh`
37. `scripts/com.pulse.hot-dev.plist.template`
38. `scripts/dev-check.sh`
39. `scripts/dev-deploy-agent.sh`
40. `scripts/dev-launchd-setup.sh`
41. `scripts/dev-launchd-wrapper.sh`
42. `scripts/hot-dev-bg.sh`
43. `scripts/hot-dev.sh`
44. `scripts/lib/hot-dev-runtime.sh`
45. `scripts/lib/hot-dev-auth.sh`
46. `scripts/install-container-agent.sh`
47. `install.sh`
48. `scripts/install.ps1`
49. `scripts/install.sh`
50. `scripts/install-mcp.sh`
51. `scripts/install-mcp.ps1`
52. `cmd/pulse-mcp/`
53. `scripts/pulse-auto-update.sh`
54. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`
55. `scripts/release_control/record_rc_to_ga_rehearsal.py`
56. `scripts/release_control/release_promotion_policy_support.py`
57. `scripts/release_control/resolve_release_promotion.py`
58. `scripts/release_ldflags.sh`
59. `scripts/run_cloud_public_signup_smoke.sh`
60. `scripts/run_demo_public_browser_smoke.sh`
61. `scripts/demo_public_browser_smoke.cjs`
62. `scripts/run_hosted_staging_smoke.sh`
63. `scripts/trigger-release-dry-run.sh`
64. `scripts/trigger-release.sh`
65. `scripts/toggle-mock.sh`
66. `deploy/provider-msp/`
67. `deploy/helm/pulse/`
68. `tests/integration/playwright.config.ts`
69. `tests/integration/QUICK_START.md`
70. `tests/integration/README.md`
71. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
72. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
73. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
74. `tests/integration/scripts/hosted-tenant-runtime.mjs`
75. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
76. `tests/integration/scripts/managed-dev-runtime.mjs`
77. `tests/integration/scripts/relay-mobile-token-helper.go`
78. `tests/integration/tests/helpers.ts`
79. `tests/integration/tests/runtime-defaults.ts`
80. `docker-compose.yml`
81. `scripts/install-docker.sh`
82. `scripts/validate-published-release.sh`
83. `scripts/validate-release.sh`
84. `scripts/release_asset_common.sh`
85. `scripts/backfill-release-assets.sh`
86. `.github/workflows/backfill-release-assets.yml`
7. `cmd/pulse-control-plane/provider_msp_proof.go`
8. `internal/cloudcp/docker/manager.go`
9. `internal/cloudcp/docker/labels.go`
10. `internal/cloudcp/tenant_runtime_rollout.go`
11. `.github/workflows/create-release.yml`
12. `.github/workflows/deploy-demo-server.yml`
13. `.github/workflows/helm-pages.yml`
14. `.github/workflows/promote-floating-tags.yml`
15. `.github/workflows/publish-docker.yml`
16. `.github/workflows/publish-helm-chart.yml`
17. `.github/workflows/release-dry-run.yml`
18. `.github/workflows/update-demo-server.yml`
19. `.github/workflows/validate-release-assets.yml`
20. `.github/workflows/install-sh-smoke.yml`
21. `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`
22. `docs/RELEASE_NOTES.md`
23. `docs/releases/`
24. `docs/UPGRADE_v6.md`
25. `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`
26. `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`
27. `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`
28. `package.json`
29. `package-lock.json`
30. `frontend-modern/package.json`
31. `frontend-modern/package-lock.json`
32. `frontend-modern/vite.config.ts`
33. `go.mod`
34. `go.sum`
35. `scripts/build-release.sh`
36. `scripts/check-workflow-dispatch-inputs.py`
37. `scripts/clean-mock-alerts.sh`
38. `scripts/com.pulse.hot-dev.plist.template`
39. `scripts/dev-check.sh`
40. `scripts/dev-deploy-agent.sh`
41. `scripts/dev-launchd-setup.sh`
42. `scripts/dev-launchd-wrapper.sh`
43. `scripts/hot-dev-bg.sh`
44. `scripts/hot-dev.sh`
45. `scripts/lib/hot-dev-runtime.sh`
46. `scripts/lib/hot-dev-auth.sh`
47. `scripts/install-container-agent.sh`
48. `install.sh`
49. `scripts/install.ps1`
50. `scripts/install.sh`
51. `scripts/install-mcp.sh`
52. `scripts/install-mcp.ps1`
53. `cmd/pulse-mcp/`
54. `scripts/pulse-auto-update.sh`
55. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`
56. `scripts/release_control/record_rc_to_ga_rehearsal.py`
57. `scripts/release_control/release_promotion_policy_support.py`
58. `scripts/release_control/resolve_release_promotion.py`
59. `scripts/release_ldflags.sh`
60. `scripts/run_cloud_public_signup_smoke.sh`
61. `scripts/run_demo_public_browser_smoke.sh`
62. `scripts/demo_public_browser_smoke.cjs`
63. `scripts/run_hosted_staging_smoke.sh`
64. `scripts/trigger-release-dry-run.sh`
65. `scripts/trigger-release.sh`
66. `scripts/toggle-mock.sh`
67. `deploy/provider-msp/`
68. `deploy/helm/pulse/`
69. `tests/integration/playwright.config.ts`
70. `tests/integration/QUICK_START.md`
71. `tests/integration/README.md`
72. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
73. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
74. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
75. `tests/integration/scripts/hosted-tenant-runtime.mjs`
76. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
77. `tests/integration/scripts/managed-dev-runtime.mjs`
78. `tests/integration/scripts/relay-mobile-token-helper.go`
79. `tests/integration/tests/helpers.ts`
80. `tests/integration/tests/runtime-defaults.ts`
81. `docker-compose.yml`
82. `scripts/install-docker.sh`
83. `scripts/validate-published-release.sh`
84. `scripts/validate-release.sh`
85. `scripts/release_asset_common.sh`
86. `scripts/backfill-release-assets.sh`
87. `.github/workflows/backfill-release-assets.yml`
## Shared Boundaries

View file

@ -1556,6 +1556,7 @@
"match_prefixes": [],
"match_files": [
"internal/api/agent_install_command_shared.go",
"internal/api/cloud_agent_install_command.go",
"internal/api/config_setup_handlers.go",
"internal/api/unified_agent.go"
],
@ -2120,6 +2121,7 @@
"exact_files": [
"internal/cloudcp/portal/frontend_sync_test.go",
"internal/cloudcp/portal/handlers_test.go",
"internal/cloudcp/portal/setup_facts_test.go",
"internal/cloudcp/routes_auth_test.go",
"internal/cloudcp/routes_test.go"
]
@ -2622,6 +2624,7 @@
".github/workflows/validate-release-assets.yml",
"cmd/pulse-control-plane/main.go",
"cmd/pulse-control-plane/provider_msp.go",
"cmd/pulse-control-plane/provider_msp_proof.go",
"docker-compose.yml",
"Dockerfile",
"docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md",
@ -2888,6 +2891,7 @@
"match_files": [
"cmd/pulse-control-plane/main.go",
"cmd/pulse-control-plane/provider_msp.go",
"cmd/pulse-control-plane/provider_msp_proof.go",
"internal/cloudcp/docker/labels.go",
"internal/cloudcp/docker/manager.go",
"internal/cloudcp/tenant_runtime_rollout.go"
@ -2895,6 +2899,7 @@
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"cmd/pulse-control-plane/provider_msp_proof_test.go",
"internal/cloudcp/tenant_runtime_rollout_test.go",
"scripts/installtests/provider_msp_deploy_test.go"
]

View file

@ -65,6 +65,13 @@ diagnostics flows may observe the API-owned `bound_agent_id`,
`agent:exec` tokens or treat a Proxmox install-command token as recoverable for
another host after the first command registration identity has been persisted.
Hosted tenant agent install commands in `internal/api/cloud_agent_install_command.go`
are adjacent API/lifecycle transport only. A provider-hosted MSP PVE/PBS install
token may allow agent reporting for the scoped tenant workspace, but it must not
grant backup visibility, recovery authority, or storage health privileges; those
remain governed by the setup-script and source-specific backup API boundaries
below.
Generated Proxmox setup-script, runtime host-agent setup, and installer
auto-registration changes that affect backup visibility permissions are
storage/recovery-adjacent: optional PVE `/storage` grants must remain effective

View file

@ -9,6 +9,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rs/zerolog/log"
)
@ -18,6 +19,98 @@ type hostedTenantAgentInstallCommandResponse struct {
Token string `json:"token"`
}
type HostedTenantAgentInstallCommandOptions struct {
Config *config.Config
Persistence *config.ConfigPersistence
MultiTenant *config.MultiTenantPersistence
TenantMonitor *monitoring.MultiTenantMonitor
HostedMode bool
OrgID string
InstallType string
OwnerUserID string
BaseURL string
}
type HostedTenantAgentInstallCommandResult struct {
OrgID string
InstallType string
Command string
Token string
TokenID string
}
var (
ErrHostedTenantInstallRequiresHostedMode = errors.New("hosted tenant agent install command requires hosted mode")
ErrHostedTenantInstallMissingOrgID = errors.New("hosted tenant agent install command requires org id")
ErrHostedTenantInstallInvalidOrg = errors.New("hosted tenant agent install command org does not exist")
ErrHostedTenantInstallMissingBaseURL = errors.New("hosted tenant agent install command requires base url")
)
func GenerateHostedTenantAgentInstallCommand(opts HostedTenantAgentInstallCommandOptions) (*HostedTenantAgentInstallCommandResult, error) {
if !opts.HostedMode {
return nil, ErrHostedTenantInstallRequiresHostedMode
}
orgID := strings.TrimSpace(opts.OrgID)
if orgID == "" {
return nil, ErrHostedTenantInstallMissingOrgID
}
if opts.MultiTenant == nil || !opts.MultiTenant.OrgExists(orgID) {
return nil, ErrHostedTenantInstallInvalidOrg
}
installType, err := normalizeProxmoxInstallType(opts.InstallType)
if err != nil {
return nil, err
}
baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
if baseURL == "" {
return nil, ErrHostedTenantInstallMissingBaseURL
}
tokenName := fmt.Sprintf("cloud-tenant-agent-%s-%s-%d", orgID, installType, time.Now().UTC().Unix())
rawToken, record, err := issueAndPersistAgentInstallToken(opts.Config, opts.Persistence, issueAgentInstallTokenOptions{
TokenName: tokenName,
OrgID: orgID,
OwnerUserID: strings.TrimSpace(opts.OwnerUserID),
Metadata: map[string]string{
"install_type": installType,
"issued_via": "hosted_agent_install_command",
},
})
if err != nil {
return nil, err
}
// If the tenant monitor is already initialized, ensure it sees the new token immediately.
// If not initialized, future GetMonitor() calls will deep-copy the updated base config.
config.Mu.Lock()
if opts.TenantMonitor != nil {
if m, ok := opts.TenantMonitor.PeekMonitor(orgID); ok && m != nil && m.GetConfig() != nil {
m.GetConfig().APITokens = append(m.GetConfig().APITokens, *record)
m.GetConfig().SortAPITokens()
}
}
config.Mu.Unlock()
command := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{
BaseURL: baseURL,
Token: rawToken,
InstallType: installType,
IncludeInstallType: true,
})
tokenID := ""
if record != nil {
tokenID = record.ID
}
return &HostedTenantAgentInstallCommandResult{
OrgID: orgID,
InstallType: installType,
Command: command,
Token: rawToken,
TokenID: tokenID,
}, nil
}
// handleHostedTenantAgentInstallCommand is a hosted-mode-only control-plane endpoint that generates a
// tenant-scoped agent install command by minting an org-bound API token.
func (r *Router) handleHostedTenantAgentInstallCommand(w http.ResponseWriter, req *http.Request) {
@ -25,21 +118,8 @@ func (r *Router) handleHostedTenantAgentInstallCommand(w http.ResponseWriter, re
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
return
}
if !r.hostedMode {
http.NotFound(w, req)
return
}
orgID := strings.TrimSpace(req.PathValue("id"))
if orgID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_org_id", "Organization ID required", nil)
return
}
if r.multiTenant == nil || !r.multiTenant.OrgExists(orgID) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_org", "Invalid Organization ID", nil)
return
}
var payload struct {
Type string `json:"type"` // "pve" or "pbs"
}
@ -48,33 +128,38 @@ func (r *Router) handleHostedTenantAgentInstallCommand(w http.ResponseWriter, re
return
}
installType, err := normalizeProxmoxInstallType(payload.Type)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
return
}
tokenName := fmt.Sprintf("cloud-tenant-agent-%s-%s-%d", orgID, installType, time.Now().UTC().Unix())
rawToken, record, err := issueAndPersistAgentInstallToken(r.config, r.persistence, issueAgentInstallTokenOptions{
TokenName: tokenName,
OrgID: orgID,
OwnerUserID: apiTokenOwnerUserIDForRequest(r.config, req),
Metadata: map[string]string{
"install_type": installType,
"issued_via": "hosted_agent_install_command",
},
result, err := GenerateHostedTenantAgentInstallCommand(HostedTenantAgentInstallCommandOptions{
Config: r.config,
Persistence: r.persistence,
MultiTenant: r.multiTenant,
TenantMonitor: r.mtMonitor,
HostedMode: r.hostedMode,
OrgID: orgID,
InstallType: payload.Type,
OwnerUserID: apiTokenOwnerUserIDForRequest(r.config, req),
BaseURL: r.resolvePublicURL(req),
})
if err != nil {
switch {
case errors.Is(err, ErrHostedTenantInstallRequiresHostedMode):
http.NotFound(w, req)
case errors.Is(err, ErrHostedTenantInstallMissingOrgID):
writeErrorResponse(w, http.StatusBadRequest, "missing_org_id", "Organization ID required", nil)
case errors.Is(err, ErrHostedTenantInstallInvalidOrg):
writeErrorResponse(w, http.StatusBadRequest, "invalid_org", "Invalid Organization ID", nil)
case errors.Is(err, ErrHostedTenantInstallMissingBaseURL):
writeErrorResponse(w, http.StatusInternalServerError, "missing_base_url", "Failed to resolve Pulse URL", nil)
case errors.Is(err, errAgentInstallTokenGeneration):
log.Error().Err(err).Msg("Failed to generate hosted tenant agent API token")
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil)
case errors.Is(err, errAgentInstallTokenRecord):
log.Error().Err(err).Str("token_name", tokenName).Msg("Failed to construct hosted tenant agent token record")
log.Error().Err(err).Str("org_id", orgID).Msg("Failed to construct hosted tenant agent token record")
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate token", nil)
case errors.Is(err, errAgentInstallTokenPersist):
log.Error().Err(err).Msg("Failed to persist hosted tenant agent token")
writeErrorResponse(w, http.StatusInternalServerError, "token_persist_failed", "Failed to save token to disk", map[string]string{"error": err.Error()})
case strings.Contains(err.Error(), "Type must be"):
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
default:
log.Error().Err(err).Msg("Failed to create hosted tenant agent token")
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil)
@ -82,28 +167,9 @@ func (r *Router) handleHostedTenantAgentInstallCommand(w http.ResponseWriter, re
return
}
// If the tenant monitor is already initialized, ensure it sees the new token immediately.
// If not initialized, future GetMonitor() calls will deep-copy the updated base config.
config.Mu.Lock()
if r.mtMonitor != nil {
if m, ok := r.mtMonitor.PeekMonitor(orgID); ok && m != nil && m.GetConfig() != nil {
m.GetConfig().APITokens = append(m.GetConfig().APITokens, *record)
m.GetConfig().SortAPITokens()
}
}
config.Mu.Unlock()
baseURL := strings.TrimRight(r.resolvePublicURL(req), "/")
command := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{
BaseURL: baseURL,
Token: rawToken,
InstallType: installType,
IncludeInstallType: true,
})
writeJSON(w, http.StatusOK, hostedTenantAgentInstallCommandResponse{
OrgID: orgID,
Command: command,
Token: rawToken,
OrgID: result.OrgID,
Command: result.Command,
Token: result.Token,
})
}

View file

@ -87,3 +87,70 @@ func TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand(t *tes
_, ok := tenantMonitor.GetConfig().ValidateAPIToken(resp.Token)
require.True(t, ok, "expected tenant monitor config to validate newly issued token")
}
func TestGenerateHostedTenantAgentInstallCommandEnforcesHostedOrgBoundary(t *testing.T) {
setMockModeForTest(t, true)
dataDir := t.TempDir()
cfg := &config.Config{
DataPath: dataDir,
PublicURL: "https://cloud.example.com",
}
persistence := config.NewConfigPersistence(dataDir)
multiTenant := config.NewMultiTenantPersistence(dataDir)
_, err := GenerateHostedTenantAgentInstallCommand(HostedTenantAgentInstallCommandOptions{
Config: cfg,
Persistence: persistence,
MultiTenant: multiTenant,
HostedMode: false,
OrgID: "acme",
InstallType: "pve",
BaseURL: "https://acme.cloud.example.com",
})
require.ErrorIs(t, err, ErrHostedTenantInstallRequiresHostedMode)
_, err = GenerateHostedTenantAgentInstallCommand(HostedTenantAgentInstallCommandOptions{
Config: cfg,
Persistence: persistence,
MultiTenant: multiTenant,
HostedMode: true,
OrgID: "missing",
InstallType: "pve",
BaseURL: "https://missing.cloud.example.com",
})
require.ErrorIs(t, err, ErrHostedTenantInstallInvalidOrg)
orgID := "acme"
_, err = multiTenant.GetPersistence(orgID)
require.NoError(t, err)
result, err := GenerateHostedTenantAgentInstallCommand(HostedTenantAgentInstallCommandOptions{
Config: cfg,
Persistence: persistence,
MultiTenant: multiTenant,
HostedMode: true,
OrgID: orgID,
InstallType: "pbs",
OwnerUserID: "owner-1",
BaseURL: "https://acme.cloud.example.com/",
})
require.NoError(t, err)
require.Equal(t, orgID, result.OrgID)
require.Equal(t, "pbs", result.InstallType)
require.NotEmpty(t, result.Command)
require.NotEmpty(t, result.Token)
require.NotEmpty(t, result.TokenID)
require.Contains(t, result.Command, "https://acme.cloud.example.com/install.sh")
require.Contains(t, result.Command, "--proxmox-type "+posixShellQuote("pbs"))
tokens, err := persistence.LoadAPITokens()
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, orgID, tokens[0].OrgID)
require.Equal(t, "owner-1", tokens[0].Metadata[apiTokenMetadataOwnerUserID])
require.Equal(t, "pbs", tokens[0].Metadata["install_type"])
require.Equal(t, "hosted_agent_install_command", tokens[0].Metadata["issued_via"])
_, ok := (&config.Config{APITokens: tokens}).ValidateAPIToken(result.Token)
require.True(t, ok)
}

View file

@ -38,28 +38,29 @@ func NewTenantDirWorkspaceSetupFactReader(tenantsDir string) WorkspaceSetupFactR
}
func (r tenantDirWorkspaceSetupFactReader) FactsForWorkspace(tenantID string) WorkspaceSetupFacts {
orgDir, ok := r.orgConfigDir(tenantID)
tenantDataDir, orgDir, ok := r.workspaceConfigDirs(tenantID)
if !ok {
return WorkspaceSetupFacts{}
}
return readWorkspaceSetupFacts(orgDir)
return readWorkspaceSetupFacts(tenantID, tenantDataDir, orgDir)
}
func (r tenantDirWorkspaceSetupFactReader) orgConfigDir(tenantID string) (string, bool) {
func (r tenantDirWorkspaceSetupFactReader) workspaceConfigDirs(tenantID string) (tenantDataDir string, orgDir string, ok bool) {
tenantID = strings.TrimSpace(tenantID)
if r.tenantsDir == "" || tenantID == "" || filepath.Base(tenantID) != tenantID {
return "", false
return "", "", false
}
return filepath.Join(r.tenantsDir, tenantID, "orgs", tenantID), true
tenantDataDir = filepath.Join(r.tenantsDir, tenantID)
return tenantDataDir, filepath.Join(tenantDataDir, "orgs", tenantID), true
}
func readWorkspaceSetupFacts(orgDir string) WorkspaceSetupFacts {
func readWorkspaceSetupFacts(tenantID, tenantDataDir, orgDir string) WorkspaceSetupFacts {
facts := WorkspaceSetupFacts{}
if orgDir == "" {
return facts
}
facts.AgentCount, facts.AgentTokenCount, facts.UnusedAgentTokenCount, facts.LastAgentSeenAt = readAgentSetupFacts(orgDir)
facts.AgentCount, facts.AgentTokenCount, facts.UnusedAgentTokenCount, facts.LastAgentSeenAt = readAgentSetupFacts(tenantID, tenantDataDir, orgDir)
facts.AlertRouteCount, facts.DisabledAlertRouteCount = readAlertRouteFacts(orgDir)
facts.ReportScheduleCount, facts.DisabledReportScheduleCount = readReportScheduleFacts(orgDir)
return facts
@ -69,17 +70,44 @@ func intPtr(value int) *int {
return &value
}
func readAgentSetupFacts(orgDir string) (*int, *int, *int, *time.Time) {
var tokens []config.APITokenRecord
ok, err := readMaybeEncryptedJSON(orgDir, "api_tokens.json", &tokens)
if err != nil {
log.Warn().Err(err).Str("org_dir", orgDir).Msg("cloudcp.portal.setup_facts: read api tokens")
return nil, nil, nil, nil
}
if !ok {
return intPtr(0), intPtr(0), intPtr(0), nil
func readAgentSetupFacts(tenantID, tenantDataDir, orgDir string) (*int, *int, *int, *time.Time) {
tokens := make([]config.APITokenRecord, 0)
seen := map[string]struct{}{}
appendTokens := func(dir, source string, filter func(config.APITokenRecord) bool) {
if dir == "" {
return
}
var sourceTokens []config.APITokenRecord
ok, err := readMaybeEncryptedJSON(dir, "api_tokens.json", &sourceTokens)
if err != nil {
log.Warn().Err(err).Str("dir", dir).Str("source", source).Msg("cloudcp.portal.setup_facts: read api tokens")
return
}
if !ok {
return
}
for _, token := range sourceTokens {
if filter != nil && !filter(token) {
continue
}
key := agentSetupTokenKey(token)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
tokens = append(tokens, token)
}
}
// Tokens created inside the org-specific config directory belong to that
// workspace by construction.
appendTokens(orgDir, "org", nil)
// Hosted tenant install commands are persisted in the tenant runtime's
// shared token store, with OrgID/OrgIDs carrying the workspace boundary.
appendTokens(tenantDataDir, "tenant-root", func(token config.APITokenRecord) bool {
return agentSetupTokenMatchesWorkspace(token, tenantID)
})
count := 0
tokenCount := 0
unusedCount := 0
@ -102,6 +130,32 @@ func readAgentSetupFacts(orgDir string) (*int, *int, *int, *time.Time) {
return intPtr(count), intPtr(tokenCount), intPtr(unusedCount), lastSeen
}
func agentSetupTokenKey(token config.APITokenRecord) string {
if strings.TrimSpace(token.ID) != "" {
return "id:" + strings.TrimSpace(token.ID)
}
if strings.TrimSpace(token.Hash) != "" {
return "hash:" + strings.TrimSpace(token.Hash)
}
return "token:" + strings.TrimSpace(token.Name) + ":" + token.CreatedAt.UTC().Format(time.RFC3339Nano) + ":" + strings.Join(token.Scopes, ",")
}
func agentSetupTokenMatchesWorkspace(token config.APITokenRecord, tenantID string) bool {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
return false
}
if strings.TrimSpace(token.OrgID) == tenantID {
return true
}
for _, orgID := range token.OrgIDs {
if strings.TrimSpace(orgID) == tenantID {
return true
}
}
return false
}
func readAlertRouteFacts(orgDir string) (*int, *int) {
enabledCount := 0
disabledCount := 0

View file

@ -114,6 +114,68 @@ func TestTenantDirWorkspaceSetupFactReaderCountsTenantFacts(t *testing.T) {
}
}
func TestTenantDirWorkspaceSetupFactReaderCountsHostedRootAgentTokens(t *testing.T) {
tenantsDir := t.TempDir()
tenantDir := filepath.Join(tenantsDir, "ws_one")
orgDir := filepath.Join(tenantDir, "orgs", "ws_one")
if err := os.MkdirAll(orgDir, 0o755); err != nil {
t.Fatal(err)
}
lastUsed := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
writeSetupFactJSON(t, tenantDir, "api_tokens.json", []config.APITokenRecord{
{
ID: "agent-used-root",
Name: "Hosted Agent",
Hash: "hash-used",
OrgID: "ws_one",
CreatedAt: lastUsed.Add(-time.Hour),
LastUsedAt: &lastUsed,
Scopes: []string{config.ScopeAgentReport},
},
{
ID: "agent-unused-root",
Name: "Hosted Agent Unused",
Hash: "hash-unused",
OrgIDs: []string{"ws_one"},
CreatedAt: lastUsed.Add(-time.Hour),
Scopes: []string{config.ScopeAgentReport},
},
{
ID: "other-workspace-agent",
Name: "Other Workspace",
Hash: "hash-other",
OrgID: "ws_two",
CreatedAt: lastUsed.Add(-time.Hour),
LastUsedAt: &lastUsed,
Scopes: []string{config.ScopeAgentReport},
},
{
ID: "settings-only-root",
Name: "Settings",
Hash: "hash-settings",
OrgID: "ws_one",
CreatedAt: lastUsed.Add(-time.Hour),
LastUsedAt: &lastUsed,
Scopes: []string{config.ScopeSettingsRead},
},
})
facts := NewTenantDirWorkspaceSetupFactReader(tenantsDir).FactsForWorkspace("ws_one")
if facts.AgentCount == nil || *facts.AgentCount != 1 {
t.Fatalf("AgentCount = %v, want 1", facts.AgentCount)
}
if facts.AgentTokenCount == nil || *facts.AgentTokenCount != 2 {
t.Fatalf("AgentTokenCount = %v, want 2", facts.AgentTokenCount)
}
if facts.UnusedAgentTokenCount == nil || *facts.UnusedAgentTokenCount != 1 {
t.Fatalf("UnusedAgentTokenCount = %v, want 1", facts.UnusedAgentTokenCount)
}
if facts.LastAgentSeenAt == nil || !facts.LastAgentSeenAt.Equal(lastUsed) {
t.Fatalf("LastAgentSeenAt = %v, want %v", facts.LastAgentSeenAt, lastUsed)
}
}
func TestTenantDirWorkspaceSetupFactReaderMissingFactsAreZero(t *testing.T) {
tenantsDir := t.TempDir()
orgDir := filepath.Join(tenantsDir, "ws_empty", "orgs", "ws_empty")

View file

@ -2860,8 +2860,8 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 327,
"heading_line": 112,
"line": 335,
"heading_line": 113,
}
],
)
@ -3405,6 +3405,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
"cmd/pulse-control-plane/provider_msp_proof_test.go",
"internal/cloudcp/tenant_runtime_rollout_test.go",
"scripts/installtests/provider_msp_deploy_test.go",
],