diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 7f4167c8a8..776c588425 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`. + ### Org managers reach the agent install path in their own organization An owner or admin of the selected organization now receives diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0e88c08791..e79dbd405f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4560,6 +4560,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 ed994ae27f..4aa2124c9c 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. + ### Org managers see org-bound settings, not recovery administration `/api/security/status` now grants an owner or admin of the selected 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 cf69ac9083..466f0d5336 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -25070,3 +25070,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) + } + } +}