Files
pulse/internal/api/cloud_agent_install_command.go
T
rcourtman 8ffc9253e1 Store tenant agent install tokens where agents authenticate
A provider MSP client workspace is a tenant organization of a hosted
runtime. Settings > Infrastructure minted its agent install tokens into the
org's own config copy (configapi getContextState). Agents never send
X-Pulse-Org-ID, so auth only looks them up in the instance inventory and
routes them by the token's org binding. Every agent installed from a client
workspace got 401 on every report, on v6.5.0 and v6.6.0, so a provider's
first client never received data.

The control plane's hosted install command already stored org-bound tokens
in the instance inventory, but bridged the tenant monitor with an
in-memory copy that agent removal revoked alone, that a tenant reload
dropped, and that diverged from the instance record. Store a tenant org's
tokens only in the instance inventory, from both paths, and let the tenant
monitor read them through Monitor.APITokenInventories: its own config, then
the instance records bound only to its org. Proxmox bootstrap and
auto-register, install-time command policy, Connections, Agent Doctor and
removal revocation now act on the record where it lives. Own config comes
first so a duplicate an earlier version persisted keeps its grant state,
and revocation commits in every inventory or in none. The default org and
single-runtime installs keep one inventory and are unchanged.

Tokens minted in a tenant org before this fix stay in the org copy and
still cannot authenticate; regenerating the install command fixes them.
2026-10-25 15:06:09 +00:00

168 lines
6.2 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log"
)
type hostedTenantAgentInstallCommandResponse struct {
OrgID string `json:"orgId"`
Command string `json:"command"`
Token string `json:"token"`
}
type HostedTenantAgentInstallCommandOptions struct {
Config *config.Config
Persistence *config.ConfigPersistence
MultiTenant *config.MultiTenantPersistence
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 := agenttokens.IssueAndPersistForOrganization(opts.Config, opts.Persistence, issueAgentInstallTokenOptions{
TokenName: tokenName,
OrgID: orgID,
OwnerUserID: strings.TrimSpace(opts.OwnerUserID),
Scopes: proxmoxAgentInstallScopes(false),
Metadata: map[string]string{
"install_type": installType,
"issued_via": "hosted_agent_install_command",
// Hosted installs have no command-authority enrollment step. Keep the
// credential monitoring-only and stamp the disabled intent so a
// reinstall clears any stale combined-runtime command policy (#1728).
agenttokens.CommandPolicyIntentMetadataKey: agenttokens.CommandPolicyIntent(false),
},
})
if err != nil {
return nil, err
}
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) {
if req.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
return
}
orgID := strings.TrimSpace(req.PathValue("id"))
var payload struct {
Type string `json:"type"` // "pve" or "pbs"
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
return
}
result, err := GenerateHostedTenantAgentInstallCommand(HostedTenantAgentInstallCommandOptions{
Config: r.config,
Persistence: r.persistence,
MultiTenant: r.multiTenant,
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("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)
}
return
}
writeJSON(w, http.StatusOK, hostedTenantAgentInstallCommandResponse{
OrgID: result.OrgID,
Command: result.Command,
Token: result.Token,
})
}