Merge pull request #2209 from rcourtman/claude/msp-hosted-install-token

Mint agent install tokens in hosted client workspaces
This commit is contained in:
rcourtman
2026-09-23 23:44:20 +01:00
committed by GitHub
6 changed files with 160 additions and 1 deletions
@@ -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
@@ -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.
@@ -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
+9 -1
View File
@@ -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 {
@@ -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)
}
}
+31
View File
@@ -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)
}
}
}