Files
pulse/pkg/auth/agent_credentials.go
2026-08-30 17:18:38 +01:00

126 lines
4.0 KiB
Go

package auth
import (
"fmt"
"slices"
"strings"
)
const (
RuntimeRoleMetadataKey = "runtime_role"
RuntimeRoleMonitoringCollector = "monitoring-collector"
RuntimeRoleActionRunner = "action-runner"
RuntimeRoleLegacyFullTrust = "legacy-full-trust"
)
var monitoringCollectorScopeOrder = []string{
ScopeAgentReport,
ScopeAgentConfigRead,
ScopeDockerReport,
ScopeKubernetesReport,
}
// MonitoringCollectorScopes returns the complete allowed collector scope
// vocabulary. Report and config-read are mandatory; provider report scopes are
// optional because Proxmox-only collectors do not need them.
func MonitoringCollectorScopes() []string {
return append([]string(nil), monitoringCollectorScopeOrder...)
}
// CanonicalizeRoleScopes reduces a role credential to its canonical authority.
// It never adds a scope. Missing mandatory scopes fail closed because silently
// adding them would be an authority increase and accepting the credential
// would violate the role contract.
func CanonicalizeRoleScopes(role string, scopes []string) ([]string, bool, error) {
role = strings.TrimSpace(role)
switch role {
case RuntimeRoleMonitoringCollector:
present := scopeSet(scopes)
for _, required := range []string{ScopeAgentReport, ScopeAgentConfigRead} {
if _, ok := present[required]; !ok {
return nil, false, fmt.Errorf("monitoring collector credential requires %s", required)
}
}
canonical := make([]string, 0, len(monitoringCollectorScopeOrder))
for _, allowed := range monitoringCollectorScopeOrder {
if _, ok := present[allowed]; ok {
canonical = append(canonical, allowed)
}
}
return canonical, !slices.Equal(scopes, canonical), nil
case RuntimeRoleActionRunner:
present := scopeSet(scopes)
if _, ok := present[ScopeAgentExec]; !ok {
return nil, false, fmt.Errorf("action runner credential requires %s", ScopeAgentExec)
}
canonical := []string{ScopeAgentExec}
return canonical, !slices.Equal(scopes, canonical), nil
case RuntimeRoleLegacyFullTrust:
present := scopeSet(scopes)
if _, ok := present[ScopeAgentExec]; !ok {
return nil, false, fmt.Errorf("legacy full-trust credential requires %s", ScopeAgentExec)
}
return append([]string(nil), scopes...), false, nil
case "":
return append([]string(nil), scopes...), false, nil
default:
return nil, false, fmt.Errorf("unsupported agent credential role %q", role)
}
}
// ValidateRoleScopes reports whether a role credential already has canonical,
// bounded authority. It is used at admission even though load-time migration
// normally repairs excess authority, providing defense against in-memory
// mutation and stale configuration snapshots.
func ValidateRoleScopes(role string, scopes []string) error {
_, _, err := CanonicalizeRoleScopes(role, scopes)
if err != nil {
return err
}
if unexpected := UnexpectedScopes(role, scopes); len(unexpected) > 0 {
return fmt.Errorf("%s credential has non-canonical scopes", strings.TrimSpace(role))
}
return nil
}
// UnexpectedScopes returns scopes that are outside the role's allowlist. It is
// diagnostics-only and never treats missing mandatory scopes as unexpected.
func UnexpectedScopes(role string, scopes []string) []string {
var allowed map[string]struct{}
switch strings.TrimSpace(role) {
case RuntimeRoleMonitoringCollector:
allowed = scopeSet(monitoringCollectorScopeOrder)
case RuntimeRoleActionRunner:
allowed = scopeSet([]string{ScopeAgentExec})
default:
return nil
}
unexpected := make([]string, 0)
seen := make(map[string]struct{})
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if _, ok := allowed[scope]; ok {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
unexpected = append(unexpected, scope)
}
slices.Sort(unexpected)
return unexpected
}
func scopeSet(scopes []string) map[string]struct{} {
result := make(map[string]struct{}, len(scopes))
for _, scope := range scopes {
result[strings.TrimSpace(scope)] = struct{}{}
}
return result
}