Enforce data handling in AI context

This commit is contained in:
rcourtman 2026-04-25 20:41:13 +01:00
parent fc5325ab80
commit dc8208cc00
10 changed files with 408 additions and 16 deletions

View file

@ -80,6 +80,11 @@ runtime cost control, and shared AI transport surfaces.
3. Preserve explicit coverage for chat, Patrol, remediation, and cost-control behavior when AI runtime changes
4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops
5. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider
External provider-bound unified-resource context must enforce the same
data-handling policy the export audit records: `local-only` resources are
represented only as aggregate posture and omitted from detailed prompt
sections, while sensitive alert text is scrubbed through the shared
unified-resource redaction helper before it reaches a non-local model.
6. Keep AI resource and incident context aligned with the canonical unified-resource timeline before falling back to patrol-local change detectors
7. Keep platform assistant read/control claims aligned with
`docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md`. New

View file

@ -106,6 +106,7 @@ visibility, and privacy controls to operators.
6. Keep the shared storage-directory and secure storage-file hardening helper aligned with the crypto manager plus control-plane magic-link key and store handling whenever runtime data-root ownership assumptions change.
7. Keep auth-env ingestion and shared fingerprint-verifier TLS defaults aligned whenever runtime auth loading or pinned-certificate transport behavior changes.
8. Keep the Data Handling settings surface neutral and non-commercial: it may show resource policy posture, local-only counts, and redaction coverage, but it must not advertise trials, upgrades, paid plans, or monitoring limits.
9. Keep operator-facing Data Handling posture aligned with runtime AI/context enforcement: `local-only` resource details must not be sent to external model prompts, and sensitive free-form alert text must use the shared resource-policy redaction helper before leaving the local trust boundary.
## Current State

View file

@ -135,6 +135,11 @@ The canonical AI-safe summary builder now owns the sensitivity-specific suffix
phrases for `sensitive` and `restricted` resources, so the backend policy
contract controls those strings instead of duplicating them inside the summary
assembly branch.
Canonical policy presentation and exact-value redaction helpers are owned in
`internal/unifiedresources/policy_presentation.go`. AI, Patrol, alert, export,
and prompt consumers must use those helpers for governed resource names,
hostnames, IP addresses, platform IDs, aliases, and paths instead of inventing
consumer-local scrubbers.
Canonical policy posture aggregation is owned here as well. Resource API
payloads may expose a camelCase transport projection, but the counts must be
derived from `internal/unifiedresources/policy_posture.go` after canonical

View file

@ -102,14 +102,16 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
workloads := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetWorkloads())
allResources := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetAll())
policyPosture := unifiedresources.SummarizePolicyPosture(allResources)
policyContext := buildUnifiedResourcePolicyContext(policyPosture)
policyContext := buildUnifiedResourcePolicyContext(policyPosture, destinationModel)
detailedInfrastructure := policyContext.filterDetailedResources(infrastructure)
detailedWorkloads := policyContext.filterDetailedResources(workloads)
byResourceID := make(map[string]unifiedresources.Resource, len(allResources))
for _, resource := range allResources {
byResourceID[resource.ID] = resource
}
sections = policyContext.appendSummarySections(sections)
if len(infrastructure) > 0 {
if len(detailedInfrastructure) > 0 {
sections = append(sections, "\n### Infrastructure (Nodes & Hosts)")
sections = append(sections, "These are the physical/virtual machines that host workloads.")
@ -120,7 +122,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
k8sClusters := make([]unifiedresources.Resource, 0)
k8sNodes := make([]unifiedresources.Resource, 0)
for _, resource := range infrastructure {
for _, resource := range detailedInfrastructure {
switch {
case resource.Type == unifiedresources.ResourceTypeK8sCluster:
k8sClusters = append(k8sClusters, resource)
@ -224,7 +226,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
for _, host := range dockerHosts {
containerCount := 0
runningCount := 0
for _, workload := range workloads {
for _, workload := range detailedWorkloads {
if workload.ParentID == nil || *workload.ParentID != host.ID {
continue
}
@ -288,12 +290,12 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
}
}
if len(workloads) > 0 {
if len(detailedWorkloads) > 0 {
sections = append(sections, "\n### Workloads (VMs & Containers)")
byParent := make(map[string][]unifiedresources.Resource)
noParent := make([]unifiedresources.Resource, 0)
for _, workload := range workloads {
for _, workload := range detailedWorkloads {
if workload.ParentID != nil && strings.TrimSpace(*workload.ParentID) != "" {
byParent[*workload.ParentID] = append(byParent[*workload.ParentID], workload)
continue
@ -301,8 +303,8 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
noParent = append(noParent, workload)
}
infraMap := make(map[string]unifiedresources.Resource, len(infrastructure))
for _, resource := range infrastructure {
infraMap := make(map[string]unifiedresources.Resource, len(detailedInfrastructure))
for _, resource := range detailedInfrastructure {
infraMap[resource.ID] = resource
}
@ -362,12 +364,15 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
storagePools := make([]unifiedresources.Resource, 0)
for _, resource := range unifiedresources.RefreshCanonicalMetadataSlice(urp.GetByType(unifiedresources.ResourceTypeStorage)) {
if !policyContext.includeResourceDetails(resource) {
continue
}
if resource.Storage != nil && strings.EqualFold(strings.TrimSpace(resource.Storage.Topology), "dataset") {
continue
}
storagePools = append(storagePools, resource)
}
physicalDisks := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetByType(unifiedresources.ResourceTypePhysicalDisk))
physicalDisks := policyContext.filterDetailedResources(unifiedresources.RefreshCanonicalMetadataSlice(urp.GetByType(unifiedresources.ResourceTypePhysicalDisk)))
if len(storagePools) > 0 || len(physicalDisks) > 0 {
sections = append(sections, "\n### Storage")
@ -449,11 +454,18 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
if len(activeAlerts) > 0 {
sections = append(sections, "\n### Resources with Active Alerts")
omittedLocalOnlyAlerts := 0
for _, alert := range activeAlerts {
displayName := strings.TrimSpace(alert.ResourceName)
message := strings.TrimSpace(alert.Message)
if resourceID := strings.TrimSpace(alert.ResourceID); resourceID != "" {
if resource, ok := byResourceID[resourceID]; ok {
if !policyContext.includeResourceDetails(resource) {
omittedLocalOnlyAlerts++
continue
}
displayName = unifiedresources.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, resource.Policy)
message = unifiedresources.ResourcePolicyRedactedText(message, resource)
} else if displayName == "" {
displayName = resourceID
}
@ -463,7 +475,10 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
}
sections = append(sections, fmt.Sprintf("- **%s**: %s (%s)",
displayName, alert.Message, alert.Level))
displayName, message, alert.Level))
}
if omittedLocalOnlyAlerts > 0 {
sections = append(sections, fmt.Sprintf("- %d alerts on local-only resources omitted from external model context by policy.", omittedLocalOnlyAlerts))
}
}
@ -528,7 +543,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
}
}
topCPU := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByCPU(3, nil))
topCPU := policyContext.filterDetailedResources(unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByCPU(3, nil)))
if len(topCPU) > 0 {
sections = append(sections, "\n### Top CPU Consumers")
for i, resource := range topCPU {
@ -541,7 +556,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
}
}
topMem := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByMemory(3, nil))
topMem := policyContext.filterDetailedResources(unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByMemory(3, nil)))
if len(topMem) > 0 {
sections = append(sections, "\n### Top Memory Consumers")
for i, resource := range topMem {
@ -554,7 +569,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s
}
}
topDisk := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByDisk(3, nil))
topDisk := policyContext.filterDetailedResources(unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByDisk(3, nil)))
if len(topDisk) > 0 {
sections = append(sections, "\n### Top Disk Usage")
for i, resource := range topDisk {

View file

@ -5,11 +5,13 @@ import (
"sort"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type unifiedResourcePolicyContext struct {
posture *unifiedresources.PolicyPostureSummary
externalModel bool
sensitivityCounts map[unifiedresources.ResourceSensitivity]int
routingCounts map[unifiedresources.ResourceRoutingScope]int
localOnlyCount int
@ -17,8 +19,9 @@ type unifiedResourcePolicyContext struct {
redactionLabels []string
}
func buildUnifiedResourcePolicyContext(posture *unifiedresources.PolicyPostureSummary) unifiedResourcePolicyContext {
func buildUnifiedResourcePolicyContext(posture *unifiedresources.PolicyPostureSummary, destinationModel string) unifiedResourcePolicyContext {
context := unifiedResourcePolicyContext{
externalModel: unifiedResourceContextUsesExternalModel(destinationModel),
posture: posture,
sensitivityCounts: map[unifiedresources.ResourceSensitivity]int{},
routingCounts: map[unifiedresources.ResourceRoutingScope]int{},
@ -41,10 +44,41 @@ func buildUnifiedResourcePolicyContext(posture *unifiedresources.PolicyPostureSu
return context
}
func unifiedResourceContextUsesExternalModel(destinationModel string) bool {
destinationModel = strings.TrimSpace(destinationModel)
if destinationModel == "" {
return false
}
provider, _ := config.ParseModelString(destinationModel)
return provider != config.AIProviderOllama
}
func (context unifiedResourcePolicyContext) hasGovernedResources() bool {
return context.posture != nil && context.posture.TotalResources > 0
}
func (context unifiedResourcePolicyContext) includeResourceDetails(resource unifiedresources.Resource) bool {
if !context.externalModel || resource.Policy == nil {
return true
}
return resource.Policy.Routing.Scope != unifiedresources.ResourceRoutingScopeLocalOnly
}
func (context unifiedResourcePolicyContext) filterDetailedResources(resources []unifiedresources.Resource) []unifiedresources.Resource {
if !context.externalModel || len(resources) == 0 {
return resources
}
filtered := make([]unifiedresources.Resource, 0, len(resources))
for _, resource := range resources {
if context.includeResourceDetails(resource) {
filtered = append(filtered, resource)
}
}
return filtered
}
func (context unifiedResourcePolicyContext) appendSummarySections(sections []string) []string {
if !context.hasGovernedResources() {
return sections
@ -58,6 +92,9 @@ func (context unifiedResourcePolicyContext) appendSummarySections(sections []str
routingParts := unifiedresources.ResourcePolicyRoutingSummaryFromCounts(context.routingCounts)
sections = append(sections, fmt.Sprintf("- Routing: %s", strings.Join(routingParts, ", ")))
sections = append(sections, fmt.Sprintf("- Local-only resources: %d", context.localOnlyCount))
if context.externalModel && context.localOnlyCount > 0 {
sections = append(sections, fmt.Sprintf("- External model handling: %d local-only resources are represented only in aggregate and omitted from detailed context.", context.localOnlyCount))
}
if len(context.redactionLabels) > 0 {
sections = append(sections, "\n### Policy Redaction Hints")

View file

@ -48,7 +48,7 @@ func TestBuildUnifiedResourcePolicyContext(t *testing.T) {
},
})
context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources))
context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "")
if !context.hasGovernedResources() {
t.Fatal("expected governed posture")
@ -85,3 +85,49 @@ func TestBuildUnifiedResourcePolicyContext(t *testing.T) {
t.Fatalf("expected canonical redaction labels, got %q", joined)
}
}
func TestBuildUnifiedResourcePolicyContextExternalModel(t *testing.T) {
localOnly := unifiedresources.Resource{
ID: "mail-1",
Name: "customer-mail",
Type: unifiedresources.ResourceTypePMG,
Status: unifiedresources.StatusWarning,
PMG: &unifiedresources.PMGData{Hostname: "mail.internal"},
}
cloudSummary := unifiedresources.Resource{
ID: "public-1",
Name: "public-node",
Type: unifiedresources.ResourceTypeAgent,
Status: unifiedresources.StatusOnline,
Tags: []string{"public"},
}
resources := unifiedresources.RefreshCanonicalMetadataSlice([]unifiedresources.Resource{localOnly, cloudSummary})
context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "openai:gpt-4o")
if !context.externalModel {
t.Fatal("expected external model handling")
}
if context.includeResourceDetails(resources[0]) {
t.Fatal("expected local-only resource details to be omitted")
}
if !context.includeResourceDetails(resources[1]) {
t.Fatal("expected cloud-summary resource details to remain available")
}
if filtered := context.filterDetailedResources(resources); len(filtered) != 1 || filtered[0].ID != "public-1" {
t.Fatalf("filtered resources = %#v, want only public-1", filtered)
}
joined := strings.Join(context.appendSummarySections(nil), "\n")
if !strings.Contains(joined, "External model handling: 1 local-only resources are represented only in aggregate and omitted from detailed context.") {
t.Fatalf("expected external handling summary, got %q", joined)
}
localContext := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "ollama:llama3")
if localContext.externalModel {
t.Fatal("expected ollama destination to stay local")
}
if !localContext.includeResourceDetails(resources[0]) {
t.Fatal("expected local model context to include local-only resource details")
}
}

View file

@ -315,9 +315,87 @@ func TestBuildUnifiedResourceContextUsesAISafeSummaryForLocalOnlyResources(t *te
}
}
func TestBuildUnifiedResourceContextForExternalModelOmitsLocalOnlyResourceDetails(t *testing.T) {
restricted := unifiedresources.Resource{
ID: "mail-1",
Name: "customer-mail-gateway",
Type: unifiedresources.ResourceTypePMG,
Status: unifiedresources.StatusWarning,
PMG: &unifiedresources.PMGData{
Hostname: "mail-gateway.internal",
},
}
publicNode := unifiedresources.Resource{
ID: "public-1",
Name: "public-node",
Type: unifiedresources.ResourceTypeAgent,
Status: unifiedresources.StatusOnline,
Tags: []string{"public"},
}
resources := unifiedresources.RefreshCanonicalMetadataSlice([]unifiedresources.Resource{restricted, publicNode})
stats := unifiedresources.ResourceStats{
Total: 2,
ByType: map[unifiedresources.ResourceType]int{
unifiedresources.ResourceTypePMG: 1,
unifiedresources.ResourceTypeAgent: 1,
},
ByStatus: map[unifiedresources.ResourceStatus]int{
unifiedresources.StatusWarning: 1,
unifiedresources.StatusOnline: 1,
},
}
mockURP := &mockUnifiedResourceProvider{
getStatsFunc: func() unifiedresources.ResourceStats { return stats },
getInfrastructureFunc: func() []unifiedresources.Resource {
return resources
},
getAllFunc: func() []unifiedresources.Resource {
return resources
},
getTopCPUFunc: func(limit int, types []unifiedresources.ResourceType) []unifiedresources.Resource {
return []unifiedresources.Resource{restricted}
},
}
s := &Service{
unifiedResourceProvider: mockURP,
alertProvider: &resourceContextAlertProvider{
active: []AlertInfo{
{
ResourceID: restricted.ID,
ResourceName: restricted.Name,
Message: "customer-mail-gateway at mail-gateway.internal is degraded",
Level: "warning",
},
},
},
}
got := s.buildUnifiedResourceContextForModel("openai:gpt-4o")
for _, raw := range []string{"customer-mail-gateway", "mail-gateway.internal", "mail gateway resource; status warning; local-only context"} {
if strings.Contains(got, raw) {
t.Fatalf("expected external context to omit local-only detail %q, got %q", raw, got)
}
}
if !strings.Contains(got, "Local-only resources: 1") {
t.Fatalf("expected aggregate local-only count, got %q", got)
}
if !strings.Contains(got, "External model handling: 1 local-only resources are represented only in aggregate and omitted from detailed context.") {
t.Fatalf("expected external model handling note, got %q", got)
}
if !strings.Contains(got, "1 alerts on local-only resources omitted from external model context by policy.") {
t.Fatalf("expected local-only alert omission note, got %q", got)
}
if strings.Contains(got, "Top CPU Consumers") {
t.Fatalf("expected local-only top consumers to be omitted, got %q", got)
}
}
func TestBuildUnifiedResourceContextUsesAISafeSummaryForSensitiveResources(t *testing.T) {
sensitiveVM := unifiedresources.Resource{
ID: "vm-1",
ID: "prod-west:101",
Name: "finance-db",
Type: unifiedresources.ResourceTypeVM,
Status: unifiedresources.StatusOnline,
@ -376,6 +454,68 @@ func TestBuildUnifiedResourceContextUsesAISafeSummaryForSensitiveResources(t *te
}
}
func TestBuildUnifiedResourceContextRedactsSensitiveAlertMessageText(t *testing.T) {
sensitiveVM := unifiedresources.Resource{
ID: "prod-west:101",
Name: "finance-db",
Type: unifiedresources.ResourceTypeVM,
Status: unifiedresources.StatusWarning,
Identity: unifiedresources.ResourceIdentity{
Hostnames: []string{"finance-db.internal"},
IPAddresses: []string{"10.10.0.5"},
},
Canonical: &unifiedresources.CanonicalIdentity{
PlatformID: "prod-west:101",
Aliases: []string{"finance-db-primary"},
},
}
unifiedresources.RefreshCanonicalMetadata(&sensitiveVM)
stats := unifiedresources.ResourceStats{
Total: 1,
ByType: map[unifiedresources.ResourceType]int{
unifiedresources.ResourceTypeVM: 1,
},
ByStatus: map[unifiedresources.ResourceStatus]int{
unifiedresources.StatusWarning: 1,
},
}
mockURP := &mockUnifiedResourceProvider{
getStatsFunc: func() unifiedresources.ResourceStats { return stats },
getWorkloadsFunc: func() []unifiedresources.Resource {
return []unifiedresources.Resource{sensitiveVM}
},
getAllFunc: func() []unifiedresources.Resource {
return []unifiedresources.Resource{sensitiveVM}
},
}
s := &Service{
unifiedResourceProvider: mockURP,
alertProvider: &resourceContextAlertProvider{
active: []AlertInfo{
{
ResourceID: sensitiveVM.ID,
ResourceName: sensitiveVM.Name,
Message: "finance-db at finance-db.internal / 10.10.0.5 on prod-west:101 is degraded",
Level: "critical",
},
},
},
}
got := s.buildUnifiedResourceContextForModel("anthropic:claude-3-5-sonnet-latest")
for _, raw := range []string{"finance-db", "finance-db.internal", "10.10.0.5", "prod-west:101"} {
if strings.Contains(got, raw) {
t.Fatalf("expected sensitive alert text to redact %q, got %q", raw, got)
}
}
if !strings.Contains(got, unifiedresources.ResourcePolicyRedactedLabel) {
t.Fatalf("expected redaction marker in alert context, got %q", got)
}
}
func TestBuildUnifiedResourceContextUsesAISafeSummaryForPathRedactedResources(t *testing.T) {
storage := unifiedresources.Resource{
ID: "storage-1",

View file

@ -562,6 +562,7 @@ func TestResourcePolicyLabelHelpersUsedByAIConsumers(t *testing.T) {
},
filepath.Join("..", "ai", "resource_context.go"): {
"unifiedresources.ResourcePolicyLabel(",
"unifiedresources.ResourcePolicyRedactedText(",
"unifiedresources.ResourceDisplayName(",
"unifiedresources.ResourceClusterName(",
"unifiedresources.ResourceIPSummary(",
@ -577,6 +578,7 @@ func TestResourcePolicyLabelHelpersUsedByAIConsumers(t *testing.T) {
"return ResourcePolicyRequiresGovernedSummary(policy)",
"Policy: sensitivity=%s, routing=%s",
"func ResourcePolicyRedactedValue(value string, policy *ResourcePolicy, hints ...ResourceRedactionHint) string",
"func ResourcePolicyRedactedText(value string, resource Resource) string",
"const ResourcePolicyRedactedLabel = \"redacted by policy\"",
"func ResourceRedactionLabelsFromHints(hints []ResourceRedactionHint) []string",
"func ResourceClusterName(resource Resource) string",

View file

@ -416,6 +416,53 @@ func TestResourcePolicyLabelHelpers(t *testing.T) {
}
}
func TestResourcePolicyRedactedText(t *testing.T) {
resource := Resource{
ID: "vm:prod-west:101",
Name: "finance-db",
Type: ResourceTypeVM,
Identity: ResourceIdentity{
Hostnames: []string{"finance-db.internal"},
IPAddresses: []string{"10.10.0.5"},
ClusterName: "prod-west",
},
Canonical: &CanonicalIdentity{
DisplayName: "finance-db",
PlatformID: "prod-west:101",
Aliases: []string{"finance-db-primary"},
},
Storage: &StorageMeta{
Path: "/mnt/pve/finance-db",
},
Policy: &ResourcePolicy{
Sensitivity: ResourceSensitivitySensitive,
Routing: ResourceRoutingPolicy{
Scope: ResourceRoutingScopeLocalFirst,
Redact: []ResourceRedactionHint{
ResourceRedactionHostname,
ResourceRedactionIPAddress,
ResourceRedactionPlatformID,
ResourceRedactionAlias,
ResourceRedactionPath,
},
},
},
}
got := ResourcePolicyRedactedText(
"finance-db at finance-db.internal / 10.10.0.5 moved from prod-west:101 alias finance-db-primary path /mnt/pve/finance-db",
resource,
)
for _, raw := range []string{"finance-db", "finance-db.internal", "10.10.0.5", "prod-west:101", "finance-db-primary", "/mnt/pve/finance-db"} {
if strings.Contains(got, raw) {
t.Fatalf("expected %q to be redacted from %q", raw, got)
}
}
if count := strings.Count(got, ResourcePolicyRedactedLabel); count < 5 {
t.Fatalf("expected redaction markers in %q", got)
}
}
func TestResourceRedactionLabelsFromHints(t *testing.T) {
got := ResourceRedactionLabelsFromHints([]ResourceRedactionHint{
ResourceRedactionPath,

View file

@ -264,6 +264,47 @@ func ResourcePolicyRedactedValue(value string, policy *ResourcePolicy, hints ...
return value
}
// ResourcePolicyRedactedText replaces exact resource identifiers that the
// canonical resource policy marks for redaction. It is intentionally
// value-based so alert, prompt, and export summaries cannot leak the same raw
// hostname, IP, alias, platform ID, or path through nearby free-form text.
func ResourcePolicyRedactedText(value string, resource Resource) string {
value = strings.TrimSpace(value)
if value == "" || resource.Policy == nil {
return value
}
candidates := resourcePolicyRedactionCandidates(resource)
if len(candidates) == 0 {
return value
}
sort.Slice(candidates, func(i, j int) bool {
if len(candidates[i].value) == len(candidates[j].value) {
return candidates[i].value < candidates[j].value
}
return len(candidates[i].value) > len(candidates[j].value)
})
redacted := value
seen := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
if _, ok := seen[candidate.value]; ok {
continue
}
seen[candidate.value] = struct{}{}
if !resourcePolicyShouldRedactCandidate(resource.Policy, candidate.hint) {
continue
}
if len([]rune(candidate.value)) < 3 {
continue
}
redacted = strings.ReplaceAll(redacted, candidate.value, ResourcePolicyRedactedLabel)
}
return redacted
}
// ResourceDisplayName returns the canonical resource display name fallback.
func ResourceDisplayName(resource Resource) string {
if resource.Canonical != nil {
@ -332,3 +373,56 @@ func ResourcePolicyRequiresGovernedSummary(policy *ResourcePolicy) bool {
}
return len(policy.Routing.Redact) > 0
}
type resourcePolicyRedactionCandidate struct {
hint ResourceRedactionHint
value string
}
func resourcePolicyShouldRedactCandidate(policy *ResourcePolicy, hint ResourceRedactionHint) bool {
if policy == nil {
return false
}
if policy.Routing.Scope == ResourceRoutingScopeLocalOnly {
return true
}
return ResourcePolicyRedacts(policy, hint)
}
func resourcePolicyRedactionCandidates(resource Resource) []resourcePolicyRedactionCandidate {
var candidates []resourcePolicyRedactionCandidate
add := func(hint ResourceRedactionHint, values ...string) {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
candidates = append(candidates, resourcePolicyRedactionCandidate{hint: hint, value: value})
}
}
}
add(ResourceRedactionHostname, resource.Name, canonicalHostname(resource))
add(ResourceRedactionHostname, resource.Identity.Hostnames...)
add(ResourceRedactionIPAddress, resource.Identity.IPAddresses...)
if resource.Canonical != nil {
add(ResourceRedactionHostname, resource.Canonical.DisplayName)
add(ResourceRedactionPlatformID, resource.Canonical.PlatformID, resource.Canonical.PrimaryID)
add(ResourceRedactionAlias, resource.Canonical.Aliases...)
}
add(ResourceRedactionPlatformID, canonicalPlatformID(resource), resource.ID)
add(ResourceRedactionAlias, resource.Identity.ClusterName)
if resource.Proxmox != nil {
add(ResourceRedactionHostname, resource.Proxmox.NodeName)
add(ResourceRedactionAlias, resource.Proxmox.ClusterName)
}
if resource.Kubernetes != nil {
add(ResourceRedactionPlatformID, resource.Kubernetes.ClusterID)
add(ResourceRedactionAlias, resource.Kubernetes.ClusterName, resource.Kubernetes.SourceName)
}
if resource.Storage != nil {
add(ResourceRedactionPath, resource.Storage.Path)
}
return candidates
}