From adf07a370a08cb9b9da26bd462f2797c45155d4d Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:23:20 +0100 Subject: [PATCH] Mint agent install tokens in hosted client workspaces Every provider MSP client workspace runs in hosted mode, where users sign in through the control plane handoff. The install endpoints decided whether to mint a token from the workspace's own config, which on a fresh client has no local credential, API token, proxy secret or SSO provider. So Settings > Infrastructure > Generate token answered 200 with an empty token and the Proxmox flow built a command without one: a provider could not install a single agent from the client workspace. Found on a fresh v6.4.1 provider bundle. The router's configTransferAuthenticationConfigured already counts hosted mode as enforced authentication; the agent-lifecycle copy of the rule never gained that term. Add it. Self-hosted runtimes with no auth keep the token-less command. --- .../v6/internal/subsystems/agent-lifecycle.md | 17 ++++ .../v6/internal/subsystems/api-contracts.md | 13 +++ .../internal/subsystems/storage-recovery.md | 9 +++ internal/api/configapi/dependencies.go | 10 ++- .../hosted_agent_install_token_test.go | 81 +++++++++++++++++++ internal/api/contract_test.go | 31 +++++++ 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 internal/api/configapi/hosted_agent_install_token_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 40bf3815e5..8c728cf591 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -3410,6 +3410,23 @@ Agent` secondary handoff against the live setup wizard instead of relying ## Current State +### Hosted runtimes always mint agent install tokens + +`POST /api/agent-install-command` mints and embeds an agent install token +whenever the runtime enforces authentication. A hosted runtime +(`PULSE_HOSTED_MODE=true`, which every provider MSP client workspace runs) +always does, so `ConfigHandlers.authConfiguredForAgentLifecycle` now treats +hosted mode as configured authentication, the same term the router's +`configTransferAuthenticationConfigured` already carried. Before, a fresh +client workspace had no local credential, API token, proxy secret or SSO +provider for the per-config check to find: the host flow answered `200` with +an empty token and the Proxmox flow built a command with none, so an agent +installed from the provider's client workspace could not report. Self-hosted +runtimes with no authentication configured keep the token-less command. +Regression coverage: `TestHostedRuntimeMintsAgentInstallTokensWithoutLocalAuth` +and `TestSelfHostedRuntimeWithoutAuthStillOmitsInstallToken` in +`internal/api/configapi/hosted_agent_install_token_test.go`. + ### Auto-register preserves the operator's stored TLS choice Re-registering an existing Proxmox node through the canonical auto-register diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e3f8ae9332..c44a04dc13 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4541,6 +4541,19 @@ auto-register mutation boundary. ## Current State +### Hosted install command payloads carry a minted token + +`POST /api/agent-install-command` returns a non-empty `token` (and, for +Proxmox types, a `command` embedding it) on any hosted runtime, including a +fresh provider MSP client workspace whose config holds no local credential, +API token, proxy secret or SSO provider. Hosted mode is enforced +authentication for this decision, as it already is for +`configTransferAuthenticationConfigured`. The response shape is unchanged; a +self-hosted runtime with no authentication configured still receives the +token-less payload. `TestContract_HostedRuntimeAgentInstallCommandCarriesToken` +in `internal/api/contract_test.go` pins the hosted payload through the +router-built config handlers. + ### Node auto-register TLS fields are operator-owned on update The `/api/auto-register` completion request and response shapes are unchanged. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 66774049f4..857bca4bb3 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2618,6 +2618,15 @@ vdev layout is reported` in ## Current State +### Hosted install tokens persist like any other workspace token + +Hosted client workspaces now mint agent install tokens where they previously +returned none. Each token is issued through `issueAndPersistAgentInstallToken` +into the workspace's own config persistence, the same store and write path as +every other API token, so backup, restore and recovery of a client workspace +carry these tokens without any new storage surface. A restored workspace keeps +its agents' tokens; revoking one remains Settings → Infrastructure → Tokens. + ### TLS verification preference survives node re-registration A PVE or PBS node whose operator disabled certificate verification keeps that diff --git a/internal/api/configapi/dependencies.go b/internal/api/configapi/dependencies.go index 06561b33f4..67d68a8394 100644 --- a/internal/api/configapi/dependencies.go +++ b/internal/api/configapi/dependencies.go @@ -129,8 +129,16 @@ func agentInstallCommandPolicyIntent(enableCommands bool) string { return agenttokens.CommandPolicyIntent(enableCommands) } +// authConfiguredForAgentLifecycle decides whether install commands carry a +// minted agent token. A hosted runtime always enforces authentication, the +// same as the router's configTransferAuthenticationConfigured says: its +// sessions arrive through the control plane handoff, so a fresh client +// workspace has no local credential, API token, proxy secret or SSO provider +// for the per-config check to find. Without this term every install command +// from a provider's client workspace came back without a token, and an agent +// installed from it could not report. func (h *ConfigHandlers) authConfiguredForAgentLifecycle(cfg *config.Config) bool { - return h.runtimeDependencies().AuthConfigured(cfg) + return h.hostedMode || h.runtimeDependencies().AuthConfigured(cfg) } func (h *ConfigHandlers) apiTokenOwnerUserIDForRequest(cfg *config.Config, r *http.Request) string { diff --git a/internal/api/configapi/hosted_agent_install_token_test.go b/internal/api/configapi/hosted_agent_install_token_test.go new file mode 100644 index 0000000000..b1b1c4bb73 --- /dev/null +++ b/internal/api/configapi/hosted_agent_install_token_test.go @@ -0,0 +1,81 @@ +package configapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// A provider MSP client workspace runs in hosted mode with no local +// credential, API token, proxy secret or SSO provider: its users sign in +// through the control plane handoff. The install endpoints still have to mint +// a token, because the runtime enforces authentication and an agent without +// one cannot report. Before hosted mode counted as configured auth, the host +// flow answered 200 with an empty token and the Proxmox flow built a command +// with no token in it. +func TestHostedRuntimeMintsAgentInstallTokensWithoutLocalAuth(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {name: "host agent", body: `{"type":"host","name":"client-host"}`}, + {name: "proxmox agent", body: `{"type":"pve"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{ + DataPath: t.TempDir(), + PublicURL: "https://t-client.msp.example", + } + handler := newTestConfigHandlers(t, cfg) + handler.hostedMode = true + + req := httptest.NewRequest(http.MethodPost, "/api/agent-install-command", strings.NewReader(tc.body)) + req.Host = "t-client.msp.example" + rec := httptest.NewRecorder() + handler.HandleAgentInstallCommand(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp AgentInstallCommandResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if strings.TrimSpace(resp.Token) == "" { + t.Fatal("hosted runtime returned an install response without a token") + } + if len(cfg.APITokens) != 1 { + t.Fatalf("persisted API tokens = %d, want 1", len(cfg.APITokens)) + } + if resp.Command != "" && !strings.Contains(resp.Command, resp.Token) { + t.Fatalf("install command does not carry the minted token: %s", resp.Command) + } + }) + } +} + +// Self-hosted runtimes keep the existing rule: with no authentication +// configured at all there is nothing for a token to authenticate against. +func TestSelfHostedRuntimeWithoutAuthStillOmitsInstallToken(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir(), PublicURL: "http://pulse.local:7655"} + handler := newTestConfigHandlers(t, cfg) + + req := httptest.NewRequest(http.MethodPost, "/api/agent-install-command", strings.NewReader(`{"type":"host","name":"open-host"}`)) + req.Host = "pulse.local:7655" + rec := httptest.NewRecorder() + handler.HandleAgentInstallCommand(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var resp AgentInstallCommandResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Token != "" || len(cfg.APITokens) != 0 { + t.Fatalf("unauthenticated self-hosted runtime minted a token: %+v", resp) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index d293e613c1..dc32c0e350 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -24960,3 +24960,34 @@ func TestContract_QuickSetupSynchronizesConfiguredAdminCapabilities(t *testing.T t.Fatal("startup synchronisation did not restore capabilities") } } + +// A provider MSP client workspace is a hosted runtime whose users sign in +// through the control plane handoff, so its config carries no local +// credential, API token, proxy secret or SSO provider. The install command +// payload must still carry a minted token there, or an agent installed from +// the client workspace cannot report. +func TestContract_HostedRuntimeAgentInstallCommandCarriesToken(t *testing.T) { + router := newConfigTransferTestRouter(t, true, nil) + router.config.PublicURL = "https://t-client.msp.example" + + for _, body := range []string{`{"type":"host","name":"client-host"}`, `{"type":"pve"}`} { + req := httptest.NewRequest(http.MethodPost, "/api/agent-install-command", strings.NewReader(body)) + req.Host = "t-client.msp.example" + rec := httptest.NewRecorder() + router.configHandlers.HandleAgentInstallCommand(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status = %d, body=%s", body, rec.Code, rec.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("%s: decode: %v", body, err) + } + token, _ := payload["token"].(string) + if strings.TrimSpace(token) == "" { + t.Fatalf("%s: hosted install payload has no token: %s", body, rec.Body.String()) + } + if command, _ := payload["command"].(string); command != "" && !strings.Contains(command, token) { + t.Fatalf("%s: install command does not carry the minted token", body) + } + } +}