mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
5134e36c28
commit
3f20c85e86
14 changed files with 1276 additions and 149 deletions
|
|
@ -13,6 +13,7 @@ func newProviderMSPCmd() *cobra.Command {
|
|||
Short: "Operate a provider-hosted MSP control plane",
|
||||
}
|
||||
cmd.AddCommand(newProviderMSPBootstrapCmd())
|
||||
cmd.AddCommand(newProviderMSPProofCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
691
cmd/pulse-control-plane/provider_msp_proof.go
Normal file
691
cmd/pulse-control-plane/provider_msp_proof.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
148
cmd/pulse-control-plane/provider_msp_proof_test.go
Normal file
148
cmd/pulse-control-plane/provider_msp_proof_test.go
Normal 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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue