Files
pulse/internal/api/agentbinding/policy.go
T
rcourtman 4742159bb3 Let agents without a command channel reduce their authority
Deploy enrollment, Proxmox auto-registration and pre-v6.1.1 installs
leave an agent's token bound to the node's hostname and to no agent ID
until the agent registers a command channel. An agent whose token lacks
agent:exec, or that never connects that channel, never gets an ID on its
token, and the safe-profile migration's authority reduction, which
matches that ID, refuses it for as long as the agent runs.

A report the monitor resolved under such a token now records the
server-resolved host ID on it when the report presented that same ID,
which in the same run is the ID the agent's command registration
presents. The shared binding policy must first-bind, migrate or backfill
that ID for the reported hostname, the token must run command sessions
in the report's organization, and the binding version makes the ID
immutable. Scopes, runtime role and command policy are untouched, so a
recorded identity grants nothing; command registration still needs
agent:exec and the same ID. When the presented and resolved IDs differ,
nothing is recorded and the command channel decides as before. As with a
command registration, the first identity recorded is final, so an agent
later restarted under a different --agent-id is refused under that ID on
the same token.
2026-10-05 23:38:18 +01:00

225 lines
9.4 KiB
Go

// Package agentbinding owns the immutable policy for install-token command-channel binding.
package agentbinding
import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
const (
VersionKey = "agent_exec_binding_version"
Version = "2"
IssuedViaConfig = "config_agent_install_command"
IssuedViaHosted = "hosted_agent_install_command"
// DeployIdentityKey records where a deploy runtime token's agent identity
// comes from. Enrollment that leaves identity to the agent writes
// DeployIdentityAgent; the one-time repair of a token enrollment bound to
// an invented agent-<hostname> writes DeployIdentityRepaired. A token with
// either value is never repaired again.
DeployIdentityKey = "deploy_identity_binding"
DeployIdentityAgent = "agent"
DeployIdentityRepaired = "repaired"
)
type Decision struct {
Admit bool
FirstBind bool
LegacyMigrate bool
RebindHostname bool
BackfillID bool
BackfillHost bool
// RepairDeployIdentity marks a LegacyMigrate that moves a historical deploy
// placeholder binding; the admission path must persist
// DeployIdentityRepaired with the new identity so it cannot recur.
RepairDeployIdentity bool
}
// EvaluateActionRunner admits only a pre-bound, typed action credential whose
// tenant-independent host identity exactly matches the registering runner.
// Action credentials are never first-use rebound or legacy-migrated.
func EvaluateActionRunner(record *config.APITokenRecord, requestedID, requestedHost string) Decision {
if record == nil || !record.HasScope(config.ScopeAgentExec) {
return Decision{}
}
if strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) != agenttokens.CredentialKindActionRunner ||
strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]) != agenttokens.ActionCapabilityTypedV1 ||
strings.TrimSpace(record.Metadata[agenttokens.ActionBindingVersionMetadataKey]) != agenttokens.ActionBindingVersion {
return Decision{}
}
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
requestedID = strings.TrimSpace(requestedID)
requestedHost = strings.TrimSpace(requestedHost)
if boundID == "" || boundHost == "" || requestedID == "" || requestedHost == "" {
return Decision{}
}
return Decision{Admit: boundID == requestedID && hostnamesMatch(boundHost, requestedHost)}
}
func Evaluate(record *config.APITokenRecord, requestedID, requestedHost string) Decision {
if record == nil {
return Decision{}
}
requestedID = strings.TrimSpace(requestedID)
requestedHost = strings.TrimSpace(requestedHost)
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
if boundID == "" && boundHost == "" {
if CanBindInstallToken(record, requestedID, requestedHost) {
return Decision{Admit: true, FirstBind: true}
}
return Decision{}
}
if canBindAutoRegisteredInstallToken(record, requestedID, requestedHost) {
return Decision{Admit: true, FirstBind: true}
}
// A historical deploy placeholder moves to a new identity only through the
// marked one-time repair, whatever its binding version. It must never fall
// through to the legacy branch below, which accepts equivalent hostnames
// and leaves no marker, so a later hostname rebind could recreate the
// placeholder shape and unlock another move.
if requestedID != boundID && historicalDeployPlaceholder(record, boundID, boundHost) {
if requestedID != "" && strings.EqualFold(boundHost, requestedHost) {
return Decision{Admit: true, LegacyMigrate: true, RepairDeployIdentity: true}
}
return Decision{}
}
if strings.TrimSpace(record.Metadata[VersionKey]) != Version &&
boundHost != "" && hostnamesMatch(boundHost, requestedHost) {
return Decision{Admit: true, LegacyMigrate: true}
}
idMatches := boundID == "" || boundID == requestedID
hostMatches := boundHost == "" || hostnamesMatch(boundHost, requestedHost)
rebindHostname := boundID != "" && boundID == requestedID && !hostMatches && requestedHost != ""
if !idMatches || (!hostMatches && !rebindHostname) {
return Decision{}
}
return Decision{
Admit: true,
RebindHostname: rebindHostname,
BackfillID: boundID == "" && boundHost != "",
BackfillHost: boundHost == "" && boundID != "",
}
}
// EvaluateReportedIdentity reports whether an authenticated agent report may
// record the reporting agent's identity on its token. It covers only a token
// bound to a hostname but not yet to an agent ID, which is how deploy
// enrollment, Proxmox auto-registration and pre-v6.1.1 installs leave it until
// a command registration, so a token without agent:exec, or whose agent never
// connects the command channel, would otherwise never name its agent.
//
// The identity the agent presented in the report must equal the identity the
// server resolved for it. In the same run that is the ID the agent's command
// registration presents, so recording it binds what the command channel would
// bind, and a disagreement (a configured agent ID, a continuity fork) records
// nothing. The token must also take a first bind, legacy migration, or
// backfill under Evaluate, the command channel's own decision, so a token
// command registration would refuse records nothing either. As with a command
// registration, the first identity recorded is final: an agent later restarted
// under a different configured ID, or replaying a report buffered by an
// earlier run, meets a bound token. Bound IDs never move, deploy placeholders
// are never repaired, hostnames never change, and only credentials that report
// under a single organization qualify.
func EvaluateReportedIdentity(record *config.APITokenRecord, presentedID, resolvedID, reportedHost string) bool {
if record == nil {
return false
}
presentedID = strings.TrimSpace(presentedID)
resolvedID = strings.TrimSpace(resolvedID)
if resolvedID == "" || presentedID != resolvedID {
return false
}
if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" ||
strings.TrimSpace(record.Metadata["bound_hostname"]) == "" {
return false
}
if len(record.GetBoundOrgs()) > 1 {
return false
}
switch strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) {
case "", agenttokens.CredentialKindLegacyFullTrust, agenttokens.CredentialKindMonitoringCollector:
default:
return false
}
// With no bound ID and a bound hostname, Evaluate admits only through a
// first bind, a legacy migration or an ID backfill, never a rebind or a
// placeholder repair; the explicit check keeps that true if Evaluate grows.
decision := Evaluate(record, resolvedID, reportedHost)
return decision.Admit && (decision.FirstBind || decision.LegacyMigrate || decision.BackfillID)
}
// historicalDeployPlaceholder reports a runtime token that deploy enrollment
// bound to the agent-<hostname> identity it used to invent. No agent keeps
// that identity past its first acknowledged report, so such a token may move
// once to the registering agent's real identity on exactly the same hostname.
// Tokens issued by current enrollment carry DeployIdentityAgent and a
// repaired token carries DeployIdentityRepaired, so neither qualifies, even if
// a later hostname rebind reproduces the agent-<hostname> shape.
func historicalDeployPlaceholder(record *config.APITokenRecord, boundID, boundHost string) bool {
return strings.TrimSpace(record.Metadata["deploy_job_id"]) != "" &&
strings.TrimSpace(record.Metadata[DeployIdentityKey]) == "" &&
boundHost != "" && boundID == "agent-"+boundHost
}
func CanBindInstallToken(record *config.APITokenRecord, agentID, hostname string) bool {
if record == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(hostname) == "" {
return false
}
if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" || strings.TrimSpace(record.Metadata["bound_hostname"]) != "" {
return false
}
if !supportedInstallType(record.Metadata["install_type"]) {
return false
}
return supportedIssuer(record.Metadata["issued_via"])
}
func CanBindAutoRegisteredInstallToken(record *config.APITokenRecord, agentID, hostname string) bool {
return canBindAutoRegisteredInstallToken(record, agentID, hostname)
}
func HostnamesMatch(bound, requested string) bool { return hostnamesMatch(bound, requested) }
func canBindAutoRegisteredInstallToken(record *config.APITokenRecord, agentID, hostname string) bool {
if record == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(hostname) == "" {
return false
}
if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" || strings.TrimSpace(record.Metadata[VersionKey]) != "" {
return false
}
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
if boundHost == "" || !hostnamesMatch(boundHost, strings.TrimSpace(hostname)) {
return false
}
return supportedInstallType(record.Metadata["install_type"]) && supportedIssuer(record.Metadata["issued_via"])
}
func supportedInstallType(value string) bool {
switch strings.TrimSpace(value) {
case "pve", "pbs", "host":
return true
default:
return false
}
}
func supportedIssuer(value string) bool {
switch strings.TrimSpace(value) {
case IssuedViaConfig, IssuedViaHosted:
return true
default:
return false
}
}
func hostnamesMatch(bound, requested string) bool {
return strings.EqualFold(bound, requested) || unifiedresources.HostnamesEquivalent(bound, requested)
}