diff --git a/otdfctl/migrations/namespacedpolicy/plan_utils.go b/otdfctl/migrations/namespacedpolicy/plan_utils.go new file mode 100644 index 0000000000..c538013447 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/plan_utils.go @@ -0,0 +1,33 @@ +package namespacedpolicy + +import ( + "strings" + + "github.com/opentdf/platform/protocol/go/policy" +) + +func objectIDSet[T interface{ GetId() string }](items []T) map[string]struct{} { + ids := make(map[string]struct{}, len(items)) + for _, item := range items { + if id := item.GetId(); id != "" { + ids[id] = struct{}{} + } + } + return ids +} + +func isStandardAction(action *policy.Action) bool { + if action == nil { + return false + } + if action.GetStandard() != policy.Action_STANDARD_ACTION_UNSPECIFIED { + return true + } + + switch strings.ToLower(strings.TrimSpace(action.GetName())) { + case "create", "read", "update", "delete": + return true + default: + return false + } +} diff --git a/otdfctl/migrations/namespacedpolicy/planner.go b/otdfctl/migrations/namespacedpolicy/planner.go index dac6f9b0a0..b268936922 100644 --- a/otdfctl/migrations/namespacedpolicy/planner.go +++ b/otdfctl/migrations/namespacedpolicy/planner.go @@ -101,6 +101,15 @@ func WithInteractiveReviewer(reviewer InteractiveReviewer) Option { } func (p *Planner) Plan(ctx context.Context) (*Plan, error) { + resolved, err := p.resolve(ctx) + if err != nil { + return nil, err + } + + return finalizePlan(resolved) +} + +func (p *Planner) resolve(ctx context.Context) (*ResolvedTargets, error) { retrieved, err := p.retrieve(ctx) if err != nil { return nil, err @@ -132,7 +141,7 @@ func (p *Planner) Plan(ctx context.Context) (*Plan, error) { } } - return finalizePlan(resolved) + return resolved, nil } // Retrieve the candidate policy constructs for items within scope or dependent diff --git a/otdfctl/migrations/namespacedpolicy/prune_plan.go b/otdfctl/migrations/namespacedpolicy/prune_plan.go new file mode 100644 index 0000000000..ba9fcbc30f --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/prune_plan.go @@ -0,0 +1,108 @@ +package namespacedpolicy + +import "github.com/opentdf/platform/protocol/go/policy" + +type PruneStatus string + +const ( + PruneStatusDelete PruneStatus = "delete" + PruneStatusBlocked PruneStatus = "blocked" + PruneStatusUnresolved PruneStatus = "unresolved" +) + +type PruneStatusReasonType string + +const ( + PruneStatusReasonTypeMigratedTargetNotFound PruneStatusReasonType = "MigratedTargetNotFound" + PruneStatusReasonTypeNoMatchingLabelsFound PruneStatusReasonType = "NoMatchingLabelsFound" + PruneStatusReasonTypeMismatchedMigrationLabel PruneStatusReasonType = "MismatchedMigrationLabel" + PruneStatusReasonTypeMissingMigrationLabel PruneStatusReasonType = "MissingMigrationLabel" + PruneStatusReasonTypeInUse PruneStatusReasonType = "InUse" + PruneStatusReasonTypeNeedsMigration PruneStatusReasonType = "NeedsMigration" + PruneStatusReasonTypeRegisteredResourceSourceMismatch PruneStatusReasonType = "RegisteredResourceSourceMismatch" +) + +type PruneStatusReason struct { + Type PruneStatusReasonType `json:"type"` + Message string `json:"message"` +} + +// TargetRef identifies the migrated target object that the planner +// matched to a source object. For objects that resolve to a single migrated +// target, the prune plan uses `TargetRef`. For objects that may still be +// referenced across multiple migrated namespaces, the prune plan uses +// `TargetRefs`. +type TargetRef struct { + ID string `json:"id"` + NamespaceID string `json:"namespace_id,omitempty"` + NamespaceFQN string `json:"namespace_fqn,omitempty"` +} + +func (t TargetRef) IsZero() bool { + return len(t.ID) == 0 && len(t.NamespaceID) == 0 && len(t.NamespaceFQN) == 0 +} + +func (r PruneStatusReason) IsZero() bool { + return len(r.Type) == 0 && len(r.Message) == 0 +} + +type PrunePlan struct { + Scopes []Scope `json:"scopes"` + Actions []*PruneActionPlan `json:"actions"` + SubjectConditionSets []*PruneSubjectConditionSetPlan `json:"subject_condition_sets"` + SubjectMappings []*PruneSubjectMappingPlan `json:"subject_mappings"` + RegisteredResources []*PruneRegisteredResourcePlan `json:"registered_resources"` + ObligationTriggers []*PruneObligationTriggerPlan `json:"obligation_triggers"` +} + +// PruneActionPlan records the source action being considered for deletion and +// any migrated target actions that still reference or replace it. +type PruneActionPlan struct { + Source *policy.Action `json:"source"` + Status PruneStatus `json:"status"` + MigratedTargets []TargetRef `json:"migrated_targets,omitempty"` + Reason PruneStatusReason `json:"reason,omitzero"` +} + +// PruneSubjectConditionSetPlan records the source SCS being considered for +// deletion and any migrated target subject condition sets that still reference +// or replace it. +type PruneSubjectConditionSetPlan struct { + Source *policy.SubjectConditionSet `json:"source"` + Status PruneStatus `json:"status"` + MigratedTargets []TargetRef `json:"migrated_targets,omitempty"` + Reason PruneStatusReason `json:"reason,omitzero"` +} + +// PruneSubjectMappingPlan records the source subject mapping being considered +// for deletion and the single migrated target subject mapping matched to it by +// migration metadata. +type PruneSubjectMappingPlan struct { + Source *policy.SubjectMapping `json:"source"` + Status PruneStatus `json:"status"` + MigratedTarget TargetRef `json:"migrated_target,omitzero"` + Reason PruneStatusReason `json:"reason,omitzero"` +} + +// PruneRegisteredResourcePlan records the resolved RR source being considered +// for deletion and the single migrated target RR matched to it by migration +// metadata. +type PruneRegisteredResourcePlan struct { + // Source is the resolved RR source from planning and may be filtered by interactive review. + Source *policy.RegisteredResource `json:"source"` + // FullSource is the authoritative RR source reloaded from the global namespace for prune verification. + FullSource *policy.RegisteredResource `json:"full_source,omitempty"` + Status PruneStatus `json:"status"` + MigratedTarget TargetRef `json:"migrated_target,omitzero"` + Reason PruneStatusReason `json:"reason,omitzero"` +} + +// PruneObligationTriggerPlan records the source obligation trigger being +// considered for deletion and the single migrated target obligation trigger +// matched to it by migration metadata. +type PruneObligationTriggerPlan struct { + Source *policy.ObligationTrigger `json:"source"` + Status PruneStatus `json:"status"` + MigratedTarget TargetRef `json:"migrated_target,omitzero"` + Reason PruneStatusReason `json:"reason,omitzero"` +} diff --git a/otdfctl/migrations/namespacedpolicy/prune_planner.go b/otdfctl/migrations/namespacedpolicy/prune_planner.go new file mode 100644 index 0000000000..0ba0453628 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/prune_planner.go @@ -0,0 +1,721 @@ +package namespacedpolicy + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" +) + +const ( + pruneStatusReasonMessageMigratedTargetNotFound = "migrated target not found" + pruneStatusReasonMessageInUse = "in-use" + pruneStatusReasonMessageNoMatchingLabelsFound = "no canonical migrated target had a matching migration label" + pruneStatusReasonMessageMismatchedMigrationLabel = "migrated target has mismatched migration label" + pruneStatusReasonMessageMissingMigrationLabel = "migrated target missing migration label" + pruneStatusReasonMessageNeedsMigration = "needs-migration" + pruneStatusReasonMessageRegisteredResourceSourceMismatchFmt = "source registered resource contains values outside resolved migration view for target namespace %q; manual review required before source deletion" +) + +var ( + ErrMultiplePruneScopes = errors.New("prune planner accepts exactly one scope") + ErrInvalidPruneResolvedTarget = errors.New("invalid prune resolved target") + ErrInvalidPruneResolvedSource = errors.New("invalid prune resolved source") +) + +// PrunePlanner classifies whether legacy policy objects can be deleted after +// migration. It accepts exactly one scope and uses one of two strategies: +// +// - actions and subject condition sets are planned directly from currently +// listed source objects because they are expected to be deleted last, after +// their legacy dependents are gone +// - subject mappings, registered resources, and obligation triggers reuse the +// migration planner's resolved view because prune decisions for those scopes +// still depend on resolved migrated targets +// +// Each prune item is classified as delete, blocked, or unresolved and carries +// the migrated target context that justified that decision. +type PrunePlanner struct { + planner *Planner + scopes scopeSet +} + +type prunePlannerConfig struct { + pageSize int32 + reviewer InteractiveReviewer +} + +type PruneOption func(*prunePlannerConfig) + +type pruneObject interface { + GetId() string + GetMetadata() *common.Metadata +} + +type pruneSourceObject interface { + GetId() string +} + +type pruneMigratedObject interface { + pruneObject + *policy.SubjectMapping | *policy.RegisteredResource | *policy.ObligationTrigger +} + +// NewPrunePlanner constructs a single-scope prune planner on top of the shared +// migration planner infrastructure. Interactive review is still supported for +// the resolved-object scopes, and direct-prune scopes reuse the same retriever +// and namespace discovery logic. +func NewPrunePlanner(handler PolicyClient, scopeCSV string, opts ...PruneOption) (*PrunePlanner, error) { + if handler == nil { + return nil, ErrNilPlannerHandler + } + + scopes, err := ParseScopes(scopeCSV) + if err != nil { + return nil, err + } + + normalizedScopes, err := normalizeScopes(scopes) + if err != nil { + return nil, err + } + if len(normalizedScopes) != 1 { + return nil, ErrMultiplePruneScopes + } + + config := prunePlannerConfig{pageSize: defaultPlannerPageSize} + for _, opt := range opts { + opt(&config) + } + if config.pageSize <= 0 { + config.pageSize = defaultPlannerPageSize + } + + plannerOpts := []Option{WithPageSize(config.pageSize)} + if config.reviewer != nil { + plannerOpts = append(plannerOpts, WithInteractiveReviewer(config.reviewer)) + } + + planner, err := NewPlanner(handler, scopeCSV, plannerOpts...) + if err != nil { + return nil, err + } + + return &PrunePlanner{ + planner: planner, + scopes: normalizedScopes, + }, nil +} + +func WithPrunePageSize(pageSize int32) PruneOption { + return func(config *prunePlannerConfig) { + config.pageSize = pageSize + } +} + +func WithPruneInteractiveReviewer(reviewer InteractiveReviewer) PruneOption { + return func(config *prunePlannerConfig) { + config.reviewer = reviewer + } +} + +// Plan produces a prune plan for the configured scope. +// +// Actions and subject condition sets bypass resolved migration output and are +// classified directly from current legacy usage plus canonical migrated target +// lookup. Subject mappings, registered resources, and obligation triggers first +// resolve through the migration planner and then translate that resolved state +// into prune statuses. +func (p *PrunePlanner) Plan(ctx context.Context) (*PrunePlan, error) { + if p == nil || p.planner == nil { + return nil, ErrNilPlannerHandler + } + if len(p.scopes) == 0 { + return nil, ErrEmptyPlannerScope + } + if p.scopes.has(ScopeActions) { + return p.planActions(ctx) + } + if p.scopes.has(ScopeSubjectConditionSets) { + return p.planSubjectConditionSets(ctx) + } + + resolved, err := p.planner.resolve(ctx) + if err != nil { + return nil, err + } + if resolved == nil { + return nil, ErrNilResolvedTargets + } + + sourceRegisteredResources, err := p.sourceRegisteredResources(ctx) + if err != nil { + return nil, err + } + + return buildPrunePlanFromResolved(p.scopes, resolved, sourceRegisteredResources) +} + +func (p *PrunePlanner) planActions(ctx context.Context) (*PrunePlan, error) { + sourceActions, err := p.planner.retriever.retrieveActions(ctx) + if err != nil { + return nil, err + } + sourceActions = customLegacyActions(sourceActions) + + plan := &PrunePlan{ + Scopes: p.scopes.ordered(), + Actions: make([]*PruneActionPlan, 0, len(sourceActions)), + } + if len(sourceActions) == 0 { + return plan, nil + } + + usedByID, err := p.usedLegacyActionsByID(ctx, objectIDSet(sourceActions)) + if err != nil { + return nil, err + } + + namespaces, err := p.planner.retriever.listNamespaces(ctx) + if err != nil { + return nil, err + } + targetNamespaces := dedupeTargetNamespaces(namespaces) + + customActionsByNamespace, _, err := p.planner.retriever.listActionsForNamespaces(ctx, targetNamespaces) + if err != nil { + return nil, err + } + + for _, source := range sourceActions { + if source == nil || source.GetId() == "" { + continue + } + + status, reason, targets, err := pruneStatusForAction(source, usedByID, targetNamespaces, customActionsByNamespace) + if err != nil { + return nil, fmt.Errorf("action %q: %w", source.GetId(), err) + } + + plan.Actions = append(plan.Actions, &PruneActionPlan{ + Source: source, + Status: status, + MigratedTargets: targets, + Reason: reason, + }) + } + + return plan, nil +} + +func (p *PrunePlanner) planSubjectConditionSets(ctx context.Context) (*PrunePlan, error) { + sourceSCS, err := p.planner.retriever.retrieveSubjectConditionSets(ctx) + if err != nil { + return nil, err + } + + plan := &PrunePlan{ + Scopes: p.scopes.ordered(), + SubjectConditionSets: make([]*PruneSubjectConditionSetPlan, 0, len(sourceSCS)), + } + if len(sourceSCS) == 0 { + return plan, nil + } + + usedByID, err := p.usedLegacySubjectConditionSetsByID(ctx, objectIDSet(sourceSCS)) + if err != nil { + return nil, err + } + + namespaces, err := p.planner.retriever.listNamespaces(ctx) + if err != nil { + return nil, err + } + targetNamespaces := dedupeTargetNamespaces(namespaces) + + scsByNamespace, err := p.planner.retriever.listSubjectConditionSetsForNamespaces(ctx, targetNamespaces) + if err != nil { + return nil, err + } + + for _, source := range sourceSCS { + if source.GetId() == "" { + continue + } + + status, reason, targets, err := pruneStatusForSubjectConditionSet(source, usedByID, targetNamespaces, scsByNamespace) + if err != nil { + return nil, fmt.Errorf("subject condition set %q: %w", source.GetId(), err) + } + + plan.SubjectConditionSets = append(plan.SubjectConditionSets, &PruneSubjectConditionSetPlan{ + Source: source, + Status: status, + MigratedTargets: targets, + Reason: reason, + }) + } + + return plan, nil +} + +func (p *PrunePlanner) sourceRegisteredResources(ctx context.Context) (map[string]*policy.RegisteredResource, error) { + if !p.scopes.has(ScopeRegisteredResources) { + return map[string]*policy.RegisteredResource{}, nil + } + + resources, err := p.planner.retriever.retrieveRegisteredResources(ctx) + if err != nil { + return nil, err + } + + return sourceRegisteredResourcesByID(resources), nil +} + +func (p *PrunePlanner) usedLegacyActionsByID(ctx context.Context, sourceIDs map[string]struct{}) (map[string]struct{}, error) { + used := make(map[string]struct{}, len(sourceIDs)) + if len(sourceIDs) == 0 { + return used, nil + } + + subjectMappings, err := p.planner.retriever.retrieveSubjectMappings(ctx) + if err != nil { + return nil, err + } + for _, mapping := range subjectMappings { + if mapping == nil { + continue + } + for _, action := range mapping.GetActions() { + if action == nil { + continue + } + if _, ok := sourceIDs[action.GetId()]; ok { + used[action.GetId()] = struct{}{} + } + } + } + + registeredResources, err := p.planner.retriever.retrieveRegisteredResources(ctx) + if err != nil { + return nil, err + } + for _, resource := range registeredResources { + if resource == nil { + continue + } + for _, value := range resource.GetValues() { + if value == nil { + continue + } + for _, aav := range value.GetActionAttributeValues() { + if aav == nil || aav.GetAction() == nil { + continue + } + if _, ok := sourceIDs[aav.GetAction().GetId()]; ok { + used[aav.GetAction().GetId()] = struct{}{} + } + } + } + } + + obligationTriggers, err := p.planner.retriever.retrieveObligationTriggers(ctx, sourceIDs) + if err != nil { + return nil, err + } + for _, trigger := range obligationTriggers { + if trigger == nil || trigger.GetAction() == nil { + continue + } + if _, ok := sourceIDs[trigger.GetAction().GetId()]; ok { + used[trigger.GetAction().GetId()] = struct{}{} + } + } + + return used, nil +} + +func (p *PrunePlanner) usedLegacySubjectConditionSetsByID(ctx context.Context, sourceIDs map[string]struct{}) (map[string]struct{}, error) { + used := make(map[string]struct{}, len(sourceIDs)) + if len(sourceIDs) == 0 { + return used, nil + } + + subjectMappings, err := p.planner.retriever.retrieveSubjectMappings(ctx) + if err != nil { + return nil, err + } + for _, mapping := range subjectMappings { + if mapping == nil || mapping.GetSubjectConditionSet() == nil { + continue + } + scsID := mapping.GetSubjectConditionSet().GetId() + if _, ok := sourceIDs[scsID]; ok { + used[scsID] = struct{}{} + } + } + + return used, nil +} + +func buildPrunePlanFromResolved(scopes scopeSet, resolved *ResolvedTargets, sourceRegisteredResources map[string]*policy.RegisteredResource) (*PrunePlan, error) { + if resolved == nil { + return &PrunePlan{}, nil + } + + builder := newPrunePlanBuilder(scopes, resolved, sourceRegisteredResources) + return builder.build() +} + +type prunePlanBuilder struct { + scopes scopeSet + resolved *ResolvedTargets + sourceRegisteredResources map[string]*policy.RegisteredResource +} + +func newPrunePlanBuilder(scopes scopeSet, resolved *ResolvedTargets, sourceRegisteredResources map[string]*policy.RegisteredResource) *prunePlanBuilder { + return &prunePlanBuilder{ + scopes: scopes, + resolved: resolved, + sourceRegisteredResources: sourceRegisteredResources, + } +} + +func (b *prunePlanBuilder) build() (*PrunePlan, error) { + plan := &PrunePlan{ + Scopes: b.scopes.ordered(), + } + if b.scopes.has(ScopeSubjectMappings) { + subjectMappings, err := b.subjectMappings() + if err != nil { + return nil, err + } + plan.SubjectMappings = subjectMappings + } + if b.scopes.has(ScopeRegisteredResources) { + registeredResources, err := b.registeredResources() + if err != nil { + return nil, err + } + plan.RegisteredResources = registeredResources + } + if b.scopes.has(ScopeObligationTriggers) { + obligationTriggers, err := b.obligationTriggers() + if err != nil { + return nil, err + } + plan.ObligationTriggers = obligationTriggers + } + return plan, nil +} + +func (b *prunePlanBuilder) subjectMappings() ([]*PruneSubjectMappingPlan, error) { + plans := make([]*PruneSubjectMappingPlan, 0, len(b.resolved.SubjectMappings)) + + for _, mapping := range b.resolved.SubjectMappings { + if mapping == nil || mapping.Source == nil { + continue + } + + status, reason, err := pruneStatusForResolvedObject(mapping.Source, mapping.AlreadyMigrated) + if err != nil { + return nil, fmt.Errorf("subject mapping %q: %w", mapping.Source.GetId(), err) + } + plans = append(plans, &PruneSubjectMappingPlan{ + Source: mapping.Source, + Status: status, + MigratedTarget: migratedTarget(mapping.AlreadyMigrated, mapping.Namespace), + Reason: reason, + }) + } + + return plans, nil +} + +// registeredResources verifies prune safety against the authoritative source RR. +// It first reloads the full source RR and marks the plan unresolved if the +// planner's resolved source is only a filtered view. For a full-source match, it +// then classifies the RR based on whether a migrated target exists and whether +// that target carries the expected migration metadata for the source RR. +func (b *prunePlanBuilder) registeredResources() ([]*PruneRegisteredResourcePlan, error) { + plans := make([]*PruneRegisteredResourcePlan, 0, len(b.resolved.RegisteredResources)) + + for _, resource := range b.resolved.RegisteredResources { + if resource == nil { + continue + } + if resource.Source == nil { + continue + } + + fullSource, err := b.registeredResourceSource(resource) + if err != nil { + return nil, err + } + + if !registeredResourceCanonicalEqual(resource.Source, fullSource) { + plans = append(plans, &PruneRegisteredResourcePlan{ + Source: resource.Source, + FullSource: fullSource, + Status: PruneStatusUnresolved, + MigratedTarget: migratedTarget(resource.AlreadyMigrated, resource.Namespace), + Reason: newPruneReasonf( + PruneStatusReasonTypeRegisteredResourceSourceMismatch, + pruneStatusReasonMessageRegisteredResourceSourceMismatchFmt, + namespaceLabel(resource.Namespace)), + }) + continue + } + + status, reason, err := pruneStatusForRegisteredResource(fullSource, resource.AlreadyMigrated) + if err != nil { + return nil, fmt.Errorf("registered resource %q: %w", resource.Source.GetId(), err) + } + + plans = append(plans, &PruneRegisteredResourcePlan{ + Source: resource.Source, + FullSource: fullSource, + Status: status, + MigratedTarget: migratedTarget(resource.AlreadyMigrated, resource.Namespace), + Reason: reason, + }) + } + + return plans, nil +} + +func (b *prunePlanBuilder) registeredResourceSource(resource *ResolvedRegisteredResource) (*policy.RegisteredResource, error) { + source := resource.Source + if b.sourceRegisteredResources == nil { + return nil, fmt.Errorf("%w: source registered resource verifier was not loaded", ErrInvalidPruneResolvedSource) + } + + sourceID := source.GetId() + if sourceID == "" { + return nil, fmt.Errorf("%w: registered resource source has empty id", ErrInvalidPruneResolvedSource) + } + + fullSource := b.sourceRegisteredResources[sourceID] + if fullSource == nil { + return nil, fmt.Errorf("%w: registered resource source %q not found during prune verification", ErrInvalidPruneResolvedSource, sourceID) + } + + return fullSource, nil +} + +func (b *prunePlanBuilder) obligationTriggers() ([]*PruneObligationTriggerPlan, error) { + plans := make([]*PruneObligationTriggerPlan, 0, len(b.resolved.ObligationTriggers)) + + for _, trigger := range b.resolved.ObligationTriggers { + if trigger == nil || trigger.Source == nil { + continue + } + + status, reason, err := pruneStatusForResolvedObject(trigger.Source, trigger.AlreadyMigrated) + if err != nil { + return nil, fmt.Errorf("obligation trigger %q: %w", trigger.Source.GetId(), err) + } + plans = append(plans, &PruneObligationTriggerPlan{ + Source: trigger.Source, + Status: status, + MigratedTarget: migratedTarget(trigger.AlreadyMigrated, trigger.Namespace), + Reason: reason, + }) + } + + return plans, nil +} + +func sourceRegisteredResourcesByID(resources []*policy.RegisteredResource) map[string]*policy.RegisteredResource { + byID := make(map[string]*policy.RegisteredResource, len(resources)) + for _, resource := range resources { + if resource == nil || resource.GetId() == "" { + continue + } + byID[resource.GetId()] = resource + } + return byID +} + +func customLegacyActions(actions []*policy.Action) []*policy.Action { + custom := make([]*policy.Action, 0, len(actions)) + for _, action := range actions { + if action.GetId() == "" || isStandardAction(action) { + continue + } + custom = append(custom, action) + } + return custom +} + +func pruneStatusForAction(source *policy.Action, usedByID map[string]struct{}, targetNamespaces []*policy.Namespace, actionsByNamespace map[string][]*policy.Action) (PruneStatus, PruneStatusReason, []TargetRef, error) { + targets, foundCanonical, labelsMatch, err := matchedActionTargets(source, targetNamespaces, actionsByNamespace) + if err != nil { + return "", PruneStatusReason{}, nil, err + } + _, used := usedByID[source.GetId()] + status, reason, migratedTargets := pruneStatusForCanonicalTargets(used, foundCanonical, labelsMatch, targets) + return status, reason, migratedTargets, nil +} + +func matchedActionTargets(source *policy.Action, targetNamespaces []*policy.Namespace, actionsByNamespace map[string][]*policy.Action) ([]TargetRef, bool, bool, error) { + targets := make([]TargetRef, 0) + foundCanonical := false + labelsMatch := false + + for _, namespace := range targetNamespaces { + for _, target := range actionsByNamespace[namespace.GetId()] { + if target == nil || !actionCanonicalEqual(source, target) { + continue + } + if target.GetId() == "" { + return nil, false, false, fmt.Errorf("%w: migrated target for source %q has empty id", ErrInvalidPruneResolvedTarget, source.GetId()) + } + foundCanonical = true + targets = append(targets, singleMigratedTarget(target.GetId(), namespace)) + if migratedFromID(target) == source.GetId() { + labelsMatch = true + } + break + } + } + + return targets, foundCanonical, labelsMatch, nil +} + +func pruneStatusForSubjectConditionSet(source *policy.SubjectConditionSet, usedByID map[string]struct{}, targetNamespaces []*policy.Namespace, scsByNamespace map[string][]*policy.SubjectConditionSet) (PruneStatus, PruneStatusReason, []TargetRef, error) { + targets, foundCanonical, labelsMatch, err := matchedSubjectConditionSetTargets(source, targetNamespaces, scsByNamespace) + if err != nil { + return "", PruneStatusReason{}, nil, err + } + _, used := usedByID[source.GetId()] + status, reason, migratedTargets := pruneStatusForCanonicalTargets(used, foundCanonical, labelsMatch, targets) + return status, reason, migratedTargets, nil +} + +func matchedSubjectConditionSetTargets(source *policy.SubjectConditionSet, targetNamespaces []*policy.Namespace, scsByNamespace map[string][]*policy.SubjectConditionSet) ([]TargetRef, bool, bool, error) { + targets := make([]TargetRef, 0) + foundCanonical := false + labelsMatch := false + + for _, namespace := range targetNamespaces { + for _, target := range scsByNamespace[namespace.GetId()] { + if target == nil || !subjectConditionSetCanonicalEqual(source, target) { + continue + } + if target.GetId() == "" { + return nil, false, false, fmt.Errorf("%w: migrated target for source %q has empty id", ErrInvalidPruneResolvedTarget, source.GetId()) + } + foundCanonical = true + targets = append(targets, singleMigratedTarget(target.GetId(), namespace)) + if migratedFromID(target) == source.GetId() { + labelsMatch = true + } + break + } + } + + return targets, foundCanonical, labelsMatch, nil +} + +// For actions and subject condition sets, prune runs after legacy policy graph +// objects are expected to be gone, so the planner can no longer reliably infer +// which target namespace a source object was intended to migrate into. In that +// state, a canonical match is only operator context. Delete requires that no +// legacy object is still using the source and that at least one canonical match +// carries the expected migrated_from label; additional canonical matches may +// still appear in the returned targets without that label. +func pruneStatusForCanonicalTargets(used, foundCanonical, labelsMatch bool, targets []TargetRef) (PruneStatus, PruneStatusReason, []TargetRef) { + if used { + return PruneStatusBlocked, newPruneReason(PruneStatusReasonTypeInUse, pruneStatusReasonMessageInUse), targets + } + // No canonical migrated target means the source object was not represented in + // any target namespace. For actions/SCS, that is more precise than + // needs-migration because these objects may have been left unmigrated simply + // because nothing depended on them. + if !foundCanonical { + return PruneStatusBlocked, newPruneReason(PruneStatusReasonTypeMigratedTargetNotFound, pruneStatusReasonMessageMigratedTargetNotFound), nil + } + if !labelsMatch { + return PruneStatusUnresolved, newPruneReason(PruneStatusReasonTypeNoMatchingLabelsFound, pruneStatusReasonMessageNoMatchingLabelsFound), targets + } + return PruneStatusDelete, PruneStatusReason{}, targets +} + +// target is expected to already be canonically equal to the source object. +// For subject mappings and obligation triggers, that canonical check happens in +// the resolver before AlreadyMigrated is set. For registered resources, prune +// verifies canonical equality against the authoritative full source in +// registeredResources before calling this helper. +func pruneStatusForMigratedObject(target pruneObject, sourceID string) (PruneStatus, PruneStatusReason, error) { + if target.GetId() == "" { + return "", PruneStatusReason{}, fmt.Errorf("%w: migrated target for source %q has empty id", ErrInvalidPruneResolvedTarget, sourceID) + } + if migratedFromID(target) != sourceID { + return PruneStatusUnresolved, pruneStatusReasonForMigrationLabel(target), nil + } + return PruneStatusDelete, PruneStatusReason{}, nil +} + +func pruneStatusForResolvedObject[S pruneSourceObject, T pruneMigratedObject](source S, alreadyMigrated T) (PruneStatus, PruneStatusReason, error) { + if alreadyMigrated == nil { + return PruneStatusBlocked, newPruneReason(PruneStatusReasonTypeNeedsMigration, pruneStatusReasonMessageNeedsMigration), nil + } + return pruneStatusForMigratedObject(alreadyMigrated, source.GetId()) +} + +func pruneStatusForRegisteredResource(source, alreadyMigrated *policy.RegisteredResource) (PruneStatus, PruneStatusReason, error) { + if alreadyMigrated == nil { + return PruneStatusBlocked, newPruneReason(PruneStatusReasonTypeNeedsMigration, pruneStatusReasonMessageNeedsMigration), nil + } + return pruneStatusForMigratedObject(alreadyMigrated, source.GetId()) +} + +func migratedFromID(item pruneObject) string { + if item == nil { + return "" + } + return strings.TrimSpace(item.GetMetadata().GetLabels()[migrationLabelMigratedFrom]) +} + +func singleMigratedTarget(existingID string, namespace *policy.Namespace) TargetRef { + if existingID == "" { + return TargetRef{} + } + return TargetRef{ + ID: existingID, + NamespaceID: namespace.GetId(), + NamespaceFQN: namespace.GetFqn(), + } +} + +func migratedTarget(target pruneObject, namespace *policy.Namespace) TargetRef { + if target == nil { + return TargetRef{} + } + return singleMigratedTarget(target.GetId(), namespace) +} + +func newPruneReason(reasonType PruneStatusReasonType, message string) PruneStatusReason { + if reasonType == "" && strings.TrimSpace(message) == "" { + return PruneStatusReason{} + } + return PruneStatusReason{ + Type: reasonType, + Message: message, + } +} + +func newPruneReasonf(reasonType PruneStatusReasonType, format string, args ...any) PruneStatusReason { + return newPruneReason(reasonType, fmt.Sprintf(format, args...)) +} + +func pruneStatusReasonForMigrationLabel(target pruneObject) PruneStatusReason { + if migratedFromID(target) == "" { + return newPruneReason(PruneStatusReasonTypeMissingMigrationLabel, pruneStatusReasonMessageMissingMigrationLabel) + } + return newPruneReason(PruneStatusReasonTypeMismatchedMigrationLabel, pruneStatusReasonMessageMismatchedMigrationLabel) +} diff --git a/otdfctl/migrations/namespacedpolicy/prune_planner_test.go b/otdfctl/migrations/namespacedpolicy/prune_planner_test.go new file mode 100644 index 0000000000..f43986f174 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/prune_planner_test.go @@ -0,0 +1,1220 @@ +package namespacedpolicy + +import ( + "context" + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/actions" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/obligations" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPrunePlannerRejectsMultipleScopes(t *testing.T) { + t.Parallel() + + _, err := NewPrunePlanner(&plannerTestHandler{}, "actions,subject-mappings") + + require.Error(t, err) + assert.ErrorIs(t, err, ErrMultiplePruneScopes) +} + +// Scope: actions. +func TestPrunePlannerPlanBlocksActionWhenInUse(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + attributeValue := testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace) + subjectSets := testSubjectSets() + legacySCS := &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets} + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: attributeValue, + Actions: []*policy.Action{ + {Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + }, + SubjectConditionSet: legacySCS, + } + legacyValue := testRegisteredResourceValue( + "value-1", + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + attributeValue, + ), + ) + legacyValue.Id = "value-1" + legacyResource := testRegisteredResource("resource-1", "documents", legacyValue) + obligationValue := &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://example.com/obl/notify/value/email", + Obligation: &policy.Obligation{ + Namespace: targetNamespace, + }, + } + legacyTrigger := &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + AttributeValue: attributeValue, + ObligationValue: obligationValue, + } + + targetResourceValue := &policy.RegisteredResourceValue{ + Id: "target-value-1", + Value: legacyValue.GetValue(), + Metadata: migratedMetadata(legacyValue.GetId()), + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + testActionAttributeValue("target-action-1", legacyAction.GetName(), attributeValue), + }, + } + targetResource := &policy.RegisteredResource{ + Id: "target-resource-1", + Name: legacyResource.GetName(), + Metadata: migratedMetadata(legacyResource.GetId()), + Values: []*policy.RegisteredResourceValue{targetResourceValue}, + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{ + { + Id: "target-action-1", + Name: legacyAction.GetName(), + Namespace: targetNamespace, + Metadata: migratedMetadata(legacyAction.GetId()), + }, + }, + Pagination: emptyPageResponse(), + }, + }, + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + SubjectConditionSets: []*policy.SubjectConditionSet{ + { + Id: "target-scs-1", + SubjectSets: subjectSets, + Metadata: migratedMetadata(legacySCS.GetId()), + }, + }, + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + SubjectMappings: []*policy.SubjectMapping{ + { + Id: "target-mapping-1", + AttributeValue: attributeValue, + Actions: []*policy.Action{ + {Id: "target-action-1", Name: legacyAction.GetName()}, + }, + SubjectConditionSet: &policy.SubjectConditionSet{ + Id: "target-scs-1", + SubjectSets: subjectSets, + }, + Metadata: migratedMetadata(legacyMapping.GetId()), + }, + }, + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Resources: []*policy.RegisteredResource{targetResource}, + Pagination: emptyPageResponse(), + }, + }, + obligationTriggersByNamespace: map[string]*obligations.ListObligationTriggersResponse{ + "": { + Triggers: []*policy.ObligationTrigger{legacyTrigger}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Triggers: []*policy.ObligationTrigger{ + { + Id: "target-trigger-1", + Action: &policy.Action{Id: "target-action-1", Name: legacyAction.GetName()}, + AttributeValue: attributeValue, + ObligationValue: obligationValue, + Metadata: migratedMetadata(legacyTrigger.GetId()), + }, + }, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "actions") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.Actions, 1) + assert.Equal(t, PruneStatusBlocked, plan.Actions[0].Status) + assertPruneMigratedTargets(t, plan.Actions[0].MigratedTargets, targetNamespace, "target-action-1") + assert.Equal(t, PruneStatusReasonTypeInUse, plan.Actions[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageInUse, plan.Actions[0].Reason.Message) + assert.Empty(t, plan.SubjectConditionSets) + assert.Empty(t, plan.SubjectMappings) + assert.Empty(t, plan.RegisteredResources) + assert.Empty(t, plan.ObligationTriggers) +} + +func TestPrunePlannerPlanDeletesUnusedActionWhenCanonicalMigratedTargetExists(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + targetAction := &policy.Action{ + Id: "target-action-1", + Name: legacyAction.GetName(), + Namespace: targetNamespace, + Metadata: migratedMetadata(legacyAction.GetId()), + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{targetAction}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "actions") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.Actions, 1) + assert.Equal(t, PruneStatusDelete, plan.Actions[0].Status) + assertPruneMigratedTargets(t, plan.Actions[0].MigratedTargets, targetNamespace, targetAction.GetId()) + assert.True(t, plan.Actions[0].Reason.IsZero()) +} + +func TestPrunePlannerPlanMarksUnusedActionWithNoMatchingMigrationLabelsAsUnresolved(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + targetAction := &policy.Action{ + Id: "target-action-1", + Name: legacyAction.GetName(), + Namespace: targetNamespace, + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{targetAction}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "actions") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.Actions, 1) + assert.Equal(t, PruneStatusUnresolved, plan.Actions[0].Status) + assertPruneMigratedTargets(t, plan.Actions[0].MigratedTargets, targetNamespace, targetAction.GetId()) + assert.Equal(t, PruneStatusReasonTypeNoMatchingLabelsFound, plan.Actions[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageNoMatchingLabelsFound, plan.Actions[0].Reason.Message) +} + +func TestPrunePlannerPlanBlocksUnusedActionWhenMigratedTargetIsNotFound(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{ + { + Id: "target-action-1", + Name: "different", + Namespace: targetNamespace, + }, + }, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "actions") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.Actions, 1) + assert.Equal(t, PruneStatusBlocked, plan.Actions[0].Status) + assert.Empty(t, plan.Actions[0].MigratedTargets) + assert.Equal(t, PruneStatusReasonTypeMigratedTargetNotFound, plan.Actions[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageMigratedTargetNotFound, plan.Actions[0].Reason.Message) +} + +// Scope: subject condition sets. +func TestPrunePlannerPlanBlocksSubjectConditionSetWhenInUse(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + attributeValue := testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace) + subjectSets := testSubjectSets() + legacySCS := &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets} + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: attributeValue, + Actions: []*policy.Action{ + {Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + }, + SubjectConditionSet: legacySCS, + } + legacyResource := testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + attributeValue, + ), + ), + ) + obligationValue := &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://example.com/obl/notify/value/email", + Obligation: &policy.Obligation{ + Namespace: targetNamespace, + }, + } + legacyTrigger := &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + AttributeValue: attributeValue, + ObligationValue: obligationValue, + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{ + { + Id: "target-action-1", + Name: legacyAction.GetName(), + Namespace: targetNamespace, + Metadata: migratedMetadata(legacyAction.GetId()), + }, + }, + Pagination: emptyPageResponse(), + }, + }, + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + SubjectConditionSets: []*policy.SubjectConditionSet{ + { + Id: "target-scs-1", + SubjectSets: subjectSets, + Metadata: migratedMetadata(legacySCS.GetId()), + }, + }, + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + }, + obligationTriggersByNamespace: map[string]*obligations.ListObligationTriggersResponse{ + "": { + Triggers: []*policy.ObligationTrigger{legacyTrigger}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "subject-condition-sets") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.SubjectConditionSets, 1) + assert.Equal(t, PruneStatusBlocked, plan.SubjectConditionSets[0].Status) + assertPruneMigratedTargets(t, plan.SubjectConditionSets[0].MigratedTargets, targetNamespace, "target-scs-1") + assert.Equal(t, PruneStatusReasonTypeInUse, plan.SubjectConditionSets[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageInUse, plan.SubjectConditionSets[0].Reason.Message) + assert.Empty(t, plan.Actions) + assert.Empty(t, plan.SubjectMappings) + assert.Empty(t, plan.RegisteredResources) + assert.Empty(t, plan.ObligationTriggers) +} + +func TestPrunePlannerPlanDeletesUnusedSubjectConditionSetWhenCanonicalMigratedTargetExists(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + subjectSets := testSubjectSets() + legacySCS := &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets} + targetSCS := &policy.SubjectConditionSet{ + Id: "target-scs-1", + SubjectSets: subjectSets, + Metadata: migratedMetadata(legacySCS.GetId()), + } + handler := &plannerTestHandler{ + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + SubjectConditionSets: []*policy.SubjectConditionSet{targetSCS}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "subject-condition-sets") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.SubjectConditionSets, 1) + assert.Equal(t, PruneStatusDelete, plan.SubjectConditionSets[0].Status) + assertPruneMigratedTargets(t, plan.SubjectConditionSets[0].MigratedTargets, targetNamespace, targetSCS.GetId()) + assert.True(t, plan.SubjectConditionSets[0].Reason.IsZero()) +} + +func TestPrunePlannerPlanMarksUnusedSubjectConditionSetWithNoMatchingMigrationLabelsAsUnresolved(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + subjectSets := testSubjectSets() + legacySCS := &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets} + targetSCS := &policy.SubjectConditionSet{ + Id: "target-scs-1", + SubjectSets: subjectSets, + } + handler := &plannerTestHandler{ + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + SubjectConditionSets: []*policy.SubjectConditionSet{targetSCS}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "subject-condition-sets") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.SubjectConditionSets, 1) + assert.Equal(t, PruneStatusUnresolved, plan.SubjectConditionSets[0].Status) + assertPruneMigratedTargets(t, plan.SubjectConditionSets[0].MigratedTargets, targetNamespace, targetSCS.GetId()) + assert.Equal(t, PruneStatusReasonTypeNoMatchingLabelsFound, plan.SubjectConditionSets[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageNoMatchingLabelsFound, plan.SubjectConditionSets[0].Reason.Message) +} + +func TestPrunePlannerPlanBlocksUnusedSubjectConditionSetWhenMigratedTargetIsNotFound(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacySCS := &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: testSubjectSets()} + targetSCS := &policy.SubjectConditionSet{ + Id: "target-scs-1", + SubjectSets: []*policy.SubjectSet{}, + } + handler := &plannerTestHandler{ + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + SubjectConditionSets: []*policy.SubjectConditionSet{targetSCS}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "subject-condition-sets") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.SubjectConditionSets, 1) + assert.Equal(t, PruneStatusBlocked, plan.SubjectConditionSets[0].Status) + assert.Empty(t, plan.SubjectConditionSets[0].MigratedTargets) + assert.Equal(t, PruneStatusReasonTypeMigratedTargetNotFound, plan.SubjectConditionSets[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageMigratedTargetNotFound, plan.SubjectConditionSets[0].Reason.Message) +} + +// Scope: subject mappings. +func TestPrunePlannerPlanClassifiesUnmigratedSubjectMappingAsNeedsMigration(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + subjectSets := testSubjectSets() + legacySCS := &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets} + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace), + Actions: []*policy.Action{ + {Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + }, + SubjectConditionSet: legacySCS, + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + }, + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "subject-mappings") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.SubjectMappings, 1) + assert.Equal(t, PruneStatusBlocked, plan.SubjectMappings[0].Status) + assert.Equal(t, PruneStatusReasonTypeNeedsMigration, plan.SubjectMappings[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageNeedsMigration, plan.SubjectMappings[0].Reason.Message) + assert.True(t, plan.SubjectMappings[0].MigratedTarget.IsZero()) +} + +func TestPrunePlannerPlanClassifiesMissingMigrationLabelAsUnresolved(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + targetAction := &policy.Action{ + Id: "target-action-1", + Name: legacyAction.GetName(), + Namespace: targetNamespace, + Metadata: migratedMetadata(legacyAction.GetId()), + } + subjectSets := testSubjectSets() + legacySCS := &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets} + targetSCS := &policy.SubjectConditionSet{ + Id: "target-scs-1", + SubjectSets: subjectSets, + Metadata: migratedMetadata(legacySCS.GetId()), + } + attributeValue := testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace) + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: attributeValue, + Actions: []*policy.Action{ + {Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + }, + SubjectConditionSet: legacySCS, + } + targetMapping := &policy.SubjectMapping{ + Id: "target-mapping-1", + AttributeValue: attributeValue, + Actions: []*policy.Action{ + {Id: targetAction.GetId(), Name: targetAction.GetName()}, + }, + SubjectConditionSet: targetSCS, + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{targetAction}, + Pagination: emptyPageResponse(), + }, + }, + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + SubjectConditionSets: []*policy.SubjectConditionSet{targetSCS}, + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + SubjectMappings: []*policy.SubjectMapping{targetMapping}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "subject-mappings") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.SubjectMappings, 1) + assert.Equal(t, PruneStatusUnresolved, plan.SubjectMappings[0].Status) + assertPruneMigratedTarget(t, plan.SubjectMappings[0].MigratedTarget, targetNamespace, targetMapping.GetId()) + assert.Equal(t, PruneStatusReasonTypeMissingMigrationLabel, plan.SubjectMappings[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageMissingMigrationLabel, plan.SubjectMappings[0].Reason.Message) +} + +func TestPrunePlannerPlanFailsWhenMigratedTargetIDIsEmpty(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyMapping := &policy.SubjectMapping{Id: "mapping-1"} + resolved := &ResolvedTargets{ + SubjectMappings: []*ResolvedSubjectMapping{ + { + Source: legacyMapping, + Namespace: targetNamespace, + AlreadyMigrated: &policy.SubjectMapping{ + Metadata: migratedMetadata(legacyMapping.GetId()), + }, + }, + }, + } + + _, err := buildPrunePlanFromResolved(scopesFromSlice([]Scope{ScopeSubjectMappings}), resolved, nil) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidPruneResolvedTarget) + assert.Contains(t, err.Error(), `subject mapping "mapping-1"`) +} + +func TestPrunePlannerPlanClassifiesMismatchedMigrationLabelAsUnresolved(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyMapping := &policy.SubjectMapping{Id: "mapping-1"} + resolved := &ResolvedTargets{ + SubjectMappings: []*ResolvedSubjectMapping{ + { + Source: legacyMapping, + Namespace: targetNamespace, + AlreadyMigrated: &policy.SubjectMapping{ + Id: "target-mapping-1", + Metadata: &common.Metadata{ + Labels: map[string]string{ + migrationLabelMigratedFrom: "different-source-id", + }, + }, + }, + }, + }, + } + + plan, err := buildPrunePlanFromResolved(scopesFromSlice([]Scope{ScopeSubjectMappings}), resolved, nil) + + require.NoError(t, err) + require.Len(t, plan.SubjectMappings, 1) + assert.Equal(t, PruneStatusUnresolved, plan.SubjectMappings[0].Status) + assert.Equal(t, PruneStatusReasonTypeMismatchedMigrationLabel, plan.SubjectMappings[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageMismatchedMigrationLabel, plan.SubjectMappings[0].Reason.Message) +} + +// Scope: registered resources. +func TestPrunePlannerPlanSkipsRegisteredResourceWhenResolvedSourceIsMissing(t *testing.T) { + t.Parallel() + + resolved := &ResolvedTargets{ + RegisteredResources: []*ResolvedRegisteredResource{{}}, + } + + plan, err := buildPrunePlanFromResolved( + scopesFromSlice([]Scope{ScopeRegisteredResources}), + resolved, + map[string]*policy.RegisteredResource{}, + ) + + require.NoError(t, err) + require.NotNil(t, plan) + assert.Empty(t, plan.RegisteredResources) +} + +func TestPrunePlannerPlanFailsWhenRegisteredResourceSourceIsNotReloaded(t *testing.T) { + t.Parallel() + + source := testRegisteredResource("resource-1", "documents") + resolved := &ResolvedTargets{ + RegisteredResources: []*ResolvedRegisteredResource{ + {Source: source}, + }, + } + + _, err := buildPrunePlanFromResolved( + scopesFromSlice([]Scope{ScopeRegisteredResources}), + resolved, + map[string]*policy.RegisteredResource{}, + ) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidPruneResolvedSource) + assert.Contains(t, err.Error(), `registered resource source "resource-1" not found`) +} + +func TestPrunePlannerPlanClassifiesUnmigratedRegisteredResourceAsNeedsMigration(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + attributeValue := testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace) + legacyResource := testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue(legacyAction.GetId(), legacyAction.GetName(), attributeValue), + ), + ) + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "registered-resources") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.RegisteredResources, 1) + assert.Equal(t, PruneStatusBlocked, plan.RegisteredResources[0].Status) + assert.Equal(t, PruneStatusReasonTypeNeedsMigration, plan.RegisteredResources[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageNeedsMigration, plan.RegisteredResources[0].Reason.Message) + assert.True(t, plan.RegisteredResources[0].MigratedTarget.IsZero()) +} + +func TestPrunePlannerPlanMarksFilteredRegisteredResourceSourceAsUnresolved(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://left.example.com"} + rightNamespace := &policy.Namespace{Id: "ns-2", Fqn: "https://right.example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + leftValue := testRegisteredResourceValue( + "left", + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + testAttributeValue("https://left.example.com/attr/classification/value/secret", leftNamespace), + ), + ) + rightValue := testRegisteredResourceValue( + "right", + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + testAttributeValue("https://right.example.com/attr/classification/value/secret", rightNamespace), + ), + ) + legacyResource := testRegisteredResource("resource-1", "documents", leftValue, rightValue) + targetResource := &policy.RegisteredResource{ + Id: "target-resource-1", + Name: legacyResource.GetName(), + Metadata: migratedMetadata(legacyResource.GetId()), + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{leftNamespace, rightNamespace}, + Pagination: emptyPageResponse(), + }, + } + reviewer := registeredResourceFilterReviewer{ + namespace: leftNamespace, + target: targetResource, + } + + planner, err := NewPrunePlanner(handler, "registered-resources", WithPruneInteractiveReviewer(reviewer)) + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.RegisteredResources, 1) + assert.Equal(t, PruneStatusUnresolved, plan.RegisteredResources[0].Status) + assertPruneMigratedTarget(t, plan.RegisteredResources[0].MigratedTarget, leftNamespace, targetResource.GetId()) + assert.Equal(t, PruneStatusReasonTypeRegisteredResourceSourceMismatch, plan.RegisteredResources[0].Reason.Type) + assert.Contains(t, plan.RegisteredResources[0].Reason.Message, "manual review required before source deletion") + assert.Contains(t, plan.RegisteredResources[0].Reason.Message, leftNamespace.GetFqn()) + require.Len(t, plan.RegisteredResources[0].Source.GetValues(), 1) + require.Len(t, plan.RegisteredResources[0].FullSource.GetValues(), 2) +} + +func TestPrunePlannerPlanDeletesRegisteredResourceWhenFullSourceMatchesMigratedTarget(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + targetAction := &policy.Action{ + Id: "target-action-1", + Name: legacyAction.GetName(), + Namespace: targetNamespace, + Metadata: migratedMetadata(legacyAction.GetId()), + } + attributeValue := testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace) + legacyValue := testRegisteredResourceValue( + "prod", + testActionAttributeValue(legacyAction.GetId(), legacyAction.GetName(), attributeValue), + ) + legacyResource := testRegisteredResource("resource-1", "documents", legacyValue) + targetValue := testRegisteredResourceValue( + "prod", + testActionAttributeValue(targetAction.GetId(), targetAction.GetName(), attributeValue), + ) + targetResource := testRegisteredResource("target-resource-1", legacyResource.GetName(), targetValue) + targetResource.Metadata = migratedMetadata(legacyResource.GetId()) + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{targetAction}, + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Resources: []*policy.RegisteredResource{targetResource}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "registered-resources") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.RegisteredResources, 1) + assert.Equal(t, PruneStatusDelete, plan.RegisteredResources[0].Status) + assertPruneMigratedTarget(t, plan.RegisteredResources[0].MigratedTarget, targetNamespace, targetResource.GetId()) + assert.True(t, plan.RegisteredResources[0].Reason.IsZero()) + require.NotNil(t, plan.RegisteredResources[0].Source) + require.NotNil(t, plan.RegisteredResources[0].FullSource) + assert.True(t, registeredResourceCanonicalEqual(plan.RegisteredResources[0].Source, plan.RegisteredResources[0].FullSource)) +} + +// Scope: obligation triggers. +func TestPrunePlannerPlanClassifiesUnmigratedObligationTriggerAsNeedsMigration(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + attributeValue := testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace) + obligationValue := &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://example.com/obl/notify/value/email", + Obligation: &policy.Obligation{ + Namespace: targetNamespace, + }, + } + legacyTrigger := &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + AttributeValue: attributeValue, + ObligationValue: obligationValue, + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + }, + obligationTriggersByNamespace: map[string]*obligations.ListObligationTriggersResponse{ + "": { + Triggers: []*policy.ObligationTrigger{legacyTrigger}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "obligation-triggers") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.ObligationTriggers, 1) + assert.Equal(t, PruneStatusBlocked, plan.ObligationTriggers[0].Status) + assert.Equal(t, PruneStatusReasonTypeNeedsMigration, plan.ObligationTriggers[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageNeedsMigration, plan.ObligationTriggers[0].Reason.Message) + assert.True(t, plan.ObligationTriggers[0].MigratedTarget.IsZero()) +} + +func TestPrunePlannerPlanDeletesObligationTriggerWhenMigratedTargetExists(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + targetAction := &policy.Action{ + Id: "target-action-1", + Name: legacyAction.GetName(), + Namespace: targetNamespace, + Metadata: migratedMetadata(legacyAction.GetId()), + } + attributeValue := testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace) + obligationValue := &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://example.com/obl/notify/value/email", + Obligation: &policy.Obligation{ + Namespace: targetNamespace, + }, + } + legacyTrigger := &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + AttributeValue: attributeValue, + ObligationValue: obligationValue, + } + targetTrigger := &policy.ObligationTrigger{ + Id: "target-trigger-1", + Action: &policy.Action{Id: targetAction.GetId(), Name: targetAction.GetName()}, + AttributeValue: attributeValue, + ObligationValue: obligationValue, + Metadata: migratedMetadata(legacyTrigger.GetId()), + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{targetAction}, + Pagination: emptyPageResponse(), + }, + }, + obligationTriggersByNamespace: map[string]*obligations.ListObligationTriggersResponse{ + "": { + Triggers: []*policy.ObligationTrigger{legacyTrigger}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Triggers: []*policy.ObligationTrigger{targetTrigger}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "obligation-triggers") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.ObligationTriggers, 1) + assert.Equal(t, PruneStatusDelete, plan.ObligationTriggers[0].Status) + assertPruneMigratedTarget(t, plan.ObligationTriggers[0].MigratedTarget, targetNamespace, targetTrigger.GetId()) + assert.True(t, plan.ObligationTriggers[0].Reason.IsZero()) +} + +func TestPrunePlannerPlanMarksObligationTriggerWithMissingMigrationLabelAsUnresolved(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + legacyAction := &policy.Action{Id: "action-1", Name: "decrypt"} + targetAction := &policy.Action{ + Id: "target-action-1", + Name: legacyAction.GetName(), + Namespace: targetNamespace, + Metadata: migratedMetadata(legacyAction.GetId()), + } + attributeValue := testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace) + obligationValue := &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://example.com/obl/notify/value/email", + Obligation: &policy.Obligation{ + Namespace: targetNamespace, + }, + } + legacyTrigger := &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + AttributeValue: attributeValue, + ObligationValue: obligationValue, + } + targetTrigger := &policy.ObligationTrigger{ + Id: "target-trigger-1", + Action: &policy.Action{Id: targetAction.GetId(), Name: targetAction.GetName()}, + AttributeValue: attributeValue, + ObligationValue: obligationValue, + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{targetAction}, + Pagination: emptyPageResponse(), + }, + }, + obligationTriggersByNamespace: map[string]*obligations.ListObligationTriggersResponse{ + "": { + Triggers: []*policy.ObligationTrigger{legacyTrigger}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Triggers: []*policy.ObligationTrigger{targetTrigger}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPrunePlanner(handler, "obligation-triggers") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.ObligationTriggers, 1) + assert.Equal(t, PruneStatusUnresolved, plan.ObligationTriggers[0].Status) + assertPruneMigratedTarget(t, plan.ObligationTriggers[0].MigratedTarget, targetNamespace, targetTrigger.GetId()) + assert.Equal(t, PruneStatusReasonTypeMissingMigrationLabel, plan.ObligationTriggers[0].Reason.Type) + assert.Equal(t, pruneStatusReasonMessageMissingMigrationLabel, plan.ObligationTriggers[0].Reason.Message) +} + +func migratedMetadata(sourceID string) *common.Metadata { + return &common.Metadata{ + Labels: map[string]string{ + migrationLabelMigratedFrom: sourceID, + }, + } +} + +func testSubjectSets() []*policy.SubjectSet { + return []*policy.SubjectSet{ + { + ConditionGroups: []*policy.ConditionGroup{ + { + Conditions: []*policy.Condition{ + { + SubjectExternalSelectorValue: "email", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"user@example.com"}, + }, + }, + }, + }, + }, + } +} + +func assertPruneMigratedTargets(t *testing.T, actual []TargetRef, namespace *policy.Namespace, ids ...string) { + t.Helper() + + require.Len(t, actual, len(ids)) + for i, id := range ids { + assertPruneMigratedTarget(t, actual[i], namespace, id) + } +} + +func assertPruneMigratedTarget(t *testing.T, actual TargetRef, namespace *policy.Namespace, id string) { + t.Helper() + + assert.Equal(t, id, actual.ID) + assert.Equal(t, namespace.GetId(), actual.NamespaceID) + assert.Equal(t, namespace.GetFqn(), actual.NamespaceFQN) +} + +type registeredResourceFilterReviewer struct { + namespace *policy.Namespace + target *policy.RegisteredResource +} + +func (r registeredResourceFilterReviewer) Review(_ context.Context, resolved *ResolvedTargets, _ []*policy.Namespace) error { + for _, resource := range resolved.RegisteredResources { + if resource == nil || resource.Source == nil { + continue + } + + filtered, err := filterRegisteredResourceToNamespace(resource.Source, r.namespace) + if err != nil { + return err + } + resource.Source = filtered + resource.Namespace = r.namespace + resource.Unresolved = nil + resource.AlreadyMigrated = r.target + resource.NeedsCreate = false + } + + return nil +} diff --git a/otdfctl/migrations/namespacedpolicy/resolved.go b/otdfctl/migrations/namespacedpolicy/resolved.go index 5b09ffe1fe..22687a93aa 100644 --- a/otdfctl/migrations/namespacedpolicy/resolved.go +++ b/otdfctl/migrations/namespacedpolicy/resolved.go @@ -3,7 +3,6 @@ package namespacedpolicy import ( "errors" "fmt" - "strings" "github.com/opentdf/platform/protocol/go/policy" ) @@ -161,7 +160,7 @@ func (r *resolver) resolveActionTargetFromExisting(source *policy.Action, namesp } result := &ResolvedActionResult{Namespace: namespace} - if r.isStandardAction(source) { + if isStandardAction(source) { return r.resolveStandardActionTarget(source, namespace) } @@ -183,19 +182,6 @@ func (r *resolver) resolveStandardActionTarget(source *policy.Action, namespace return nil, errors.New("matching standard action not found in target namespace") } -func (r *resolver) isStandardAction(action *policy.Action) bool { - if action.GetStandard() != policy.Action_STANDARD_ACTION_UNSPECIFIED { - return true - } - - switch strings.ToLower(strings.TrimSpace(action.GetName())) { - case "create", "read", "update", "delete": - return true - default: - return false - } -} - func (r *resolver) resolveSubjectConditionSets() ([]*ResolvedSubjectConditionSet, error) { if r == nil || r.derived == nil { return nil, nil diff --git a/otdfctl/migrations/namespacedpolicy/retrieve.go b/otdfctl/migrations/namespacedpolicy/retrieve.go index e2dd853f50..4a89cf46ec 100644 --- a/otdfctl/migrations/namespacedpolicy/retrieve.go +++ b/otdfctl/migrations/namespacedpolicy/retrieve.go @@ -383,16 +383,6 @@ func (r *Retriever) retrieveObligationTriggers(ctx context.Context, legacyAction return candidates, nil } -func objectIDSet[T interface{ GetId() string }](items []T) map[string]struct{} { - ids := make(map[string]struct{}, len(items)) - for _, item := range items { - if id := item.GetId(); id != "" { - ids[id] = struct{}{} - } - } - return ids -} - func actionIDsByNamespace(namespaces []*policy.Namespace, customByNamespace, standardByNamespace map[string][]*policy.Action) map[string]map[string]struct{} { idsByNamespace := make(map[string]map[string]struct{}, len(namespaces)) for _, namespace := range dedupeTargetNamespaces(namespaces) {