diff --git a/otdfctl/cmd/migrate/namespaced_policy.go b/otdfctl/cmd/migrate/namespaced_policy.go index 77ccb7acaa..efec1c66ed 100644 --- a/otdfctl/cmd/migrate/namespaced_policy.go +++ b/otdfctl/cmd/migrate/namespaced_policy.go @@ -2,7 +2,6 @@ package migrate import ( "errors" - "os" otdfctl "github.com/opentdf/platform/otdfctl/cmd/common" namespacedpolicy "github.com/opentdf/platform/otdfctl/migrations/namespacedpolicy" @@ -61,7 +60,7 @@ func migrateNamespacedPolicy(cmd *cobra.Command, args []string) { executeNamespacedPolicyCommit(cmd, h, plan, interactive, prompter) } - if _, err := os.Stdout.WriteString(namespacedpolicy.RenderNamespacedPolicySummary(plan, commit) + "\n"); err != nil { + if _, err := cmd.OutOrStdout().Write([]byte(namespacedpolicy.RenderNamespacedPolicySummary(plan, commit) + "\n")); err != nil { cli.ExitWithError("could not write namespaced-policy summary", err) } } @@ -82,7 +81,7 @@ func confirmNamespacedPolicyCommit(cmd *cobra.Command, plan *namespacedpolicy.Pl func executeNamespacedPolicyCommit(cmd *cobra.Command, h namespacedpolicy.ExecutorHandler, plan *namespacedpolicy.Plan, interactive bool, prompter namespacedpolicy.InteractivePrompter) { if err := confirmNamespacedPolicyCommit(cmd, plan, interactive, prompter); err != nil { if errors.Is(err, namespacedpolicy.ErrNamespacedPolicyBackupNotConfirmed) || errors.Is(err, namespacedpolicy.ErrInteractiveReviewAborted) { - writeNamespacedPolicySummary(plan, false, "aborted") + writeNamespacedPolicySummary(cmd, plan, false, "aborted") } cli.ExitWithError("could not review namespaced-policy commit", err) } @@ -93,13 +92,13 @@ func executeNamespacedPolicyCommit(cmd *cobra.Command, h namespacedpolicy.Execut } if err := executor.Execute(cmd.Context(), plan); err != nil { - writeNamespacedPolicySummary(plan, true, "failure") + writeNamespacedPolicySummary(cmd, plan, true, "failure") cli.ExitWithError("could not execute namespaced-policy commit", err) } } -func writeNamespacedPolicySummary(plan *namespacedpolicy.Plan, commit bool, result string) { - if _, err := os.Stdout.WriteString(namespacedpolicy.RenderNamespacedPolicySummaryWithResult(plan, commit, result) + "\n"); err != nil { +func writeNamespacedPolicySummary(cmd *cobra.Command, plan *namespacedpolicy.Plan, commit bool, result string) { + if _, err := cmd.OutOrStdout().Write([]byte(namespacedpolicy.RenderNamespacedPolicySummaryWithResult(plan, commit, result) + "\n")); err != nil { cli.ExitWithError("could not write namespaced-policy summary", err) } } diff --git a/otdfctl/cmd/migrate/prune/namespacedPolicy.go b/otdfctl/cmd/migrate/prune/namespacedPolicy.go deleted file mode 100644 index f92bd68c3c..0000000000 --- a/otdfctl/cmd/migrate/prune/namespacedPolicy.go +++ /dev/null @@ -1,33 +0,0 @@ -package prune - -import ( - "errors" - - "github.com/opentdf/platform/otdfctl/pkg/cli" - "github.com/opentdf/platform/otdfctl/pkg/man" - "github.com/spf13/cobra" -) - -func pruneNamespacedPolicyCmd() *cobra.Command { - doc := man.Docs.GetCommand("migrate/prune/namespaced-policy", man.WithRun(pruneNamespacedPolicy)) - doc.Args = cobra.NoArgs - doc.Hidden = true - doc.Flags().StringP( - doc.GetDocFlag("scope").Name, - doc.GetDocFlag("scope").Shorthand, - doc.GetDocFlag("scope").Default, - doc.GetDocFlag("scope").Description, - ) - - return &doc.Command -} - -func pruneNamespacedPolicy(cmd *cobra.Command, args []string) { - c := cli.New(cmd, args) - c.Flags.GetRequiredString("scope") - - cli.ExitWithError( - "migrate prune namespaced-policy is not implemented", - errors.New("the migrate prune namespaced-policy workflow is not implemented yet"), - ) -} diff --git a/otdfctl/cmd/migrate/prune/namespaced_policy.go b/otdfctl/cmd/migrate/prune/namespaced_policy.go new file mode 100644 index 0000000000..0ec88822af --- /dev/null +++ b/otdfctl/cmd/migrate/prune/namespaced_policy.go @@ -0,0 +1,97 @@ +package prune + +import ( + "errors" + + otdfctl "github.com/opentdf/platform/otdfctl/cmd/common" + namespacedpolicy "github.com/opentdf/platform/otdfctl/migrations/namespacedpolicy" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +func pruneNamespacedPolicyCmd() *cobra.Command { + doc := man.Docs.GetCommand("migrate/prune/namespaced-policy", man.WithRun(pruneNamespacedPolicy)) + doc.Args = cobra.NoArgs + doc.Hidden = true + doc.Flags().StringP( + doc.GetDocFlag("scope").Name, + doc.GetDocFlag("scope").Shorthand, + doc.GetDocFlag("scope").Default, + doc.GetDocFlag("scope").Description, + ) + + return &doc.Command +} + +func pruneNamespacedPolicy(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + scope := c.Flags.GetRequiredString("scope") + prompter := &namespacedpolicy.HuhPrompter{} + + commit, err := cmd.InheritedFlags().GetBool("commit") + if err != nil { + cli.ExitWithError("could not read --commit flag", err) + } + interactive, err := cmd.InheritedFlags().GetBool("interactive") + if err != nil { + cli.ExitWithError("could not read --interactive flag", err) + } + + h := otdfctl.NewHandler(c) + defer h.Close() + + planner, err := namespacedpolicy.NewPrunePlanner(&h, scope) + if err != nil { + cli.ExitWithError("could not create namespaced-policy prune planner", err) + } + + plan, err := planner.Plan(cmd.Context()) + if err != nil { + cli.ExitWithError("could not build namespaced-policy prune plan", err) + } + + if interactive { + if err := namespacedpolicy.ReviewPrunePlan(cmd.Context(), plan, prompter); err != nil { + if errors.Is(err, namespacedpolicy.ErrInteractiveReviewAborted) { + writeNamespacedPolicyPruneSummary(cmd, plan, false, "aborted") + } + cli.ExitWithError("could not review namespaced-policy prune plan", err) + } + } + + if commit { + executeNamespacedPolicyPruneCommit(cmd, h, plan, interactive, prompter) + } + + if _, err := cmd.OutOrStdout().Write([]byte(namespacedpolicy.RenderNamespacedPolicyPruneSummary(plan, commit) + "\n")); err != nil { + cli.ExitWithError("could not write namespaced-policy prune summary", err) + } +} + +func executeNamespacedPolicyPruneCommit(cmd *cobra.Command, h namespacedpolicy.ExecutorHandler, plan *namespacedpolicy.PrunePlan, interactive bool, prompter namespacedpolicy.InteractivePrompter) { + if interactive { + if err := namespacedpolicy.ConfirmNamespacedPolicyPruneBackup(cmd.Context(), prompter); err != nil { + if errors.Is(err, namespacedpolicy.ErrNamespacedPolicyBackupNotConfirmed) { + writeNamespacedPolicyPruneSummary(cmd, plan, false, "aborted") + } + cli.ExitWithError("could not confirm namespaced-policy prune backup", err) + } + } + + executor, err := namespacedpolicy.NewExecutor(h) + if err != nil { + cli.ExitWithError("could not create namespaced-policy prune executor", err) + } + + if err := executor.ExecutePrune(cmd.Context(), plan); err != nil { + writeNamespacedPolicyPruneSummary(cmd, plan, true, "failure") + cli.ExitWithError("could not execute namespaced-policy prune commit", err) + } +} + +func writeNamespacedPolicyPruneSummary(cmd *cobra.Command, plan *namespacedpolicy.PrunePlan, commit bool, result string) { + if _, err := cmd.OutOrStdout().Write([]byte(namespacedpolicy.RenderNamespacedPolicyPruneSummaryWithResult(plan, commit, result) + "\n")); err != nil { + cli.ExitWithError("could not write namespaced-policy prune summary", err) + } +} diff --git a/otdfctl/migrations/namespacedpolicy/execute.go b/otdfctl/migrations/namespacedpolicy/execute.go index 1e24b5f7e6..5313764f41 100644 --- a/otdfctl/migrations/namespacedpolicy/execute.go +++ b/otdfctl/migrations/namespacedpolicy/execute.go @@ -23,6 +23,7 @@ var ( ErrMissingSubjectConditionSetTarget = errors.New("missing subject condition set target") ErrTargetNamespaceRequired = errors.New("target namespace is required") ErrMissingCreatedTargetID = errors.New("missing created target id") + ErrMissingPruneSourceID = errors.New("missing prune source id") ErrUnsupportedStatus = errors.New("unsupported status") ) @@ -40,6 +41,12 @@ type ExecutorHandler interface { CreateRegisteredResource(ctx context.Context, namespace string, name string, values []string, metadata *common.MetadataMutable) (*policy.RegisteredResource, error) CreateRegisteredResourceValue(ctx context.Context, resourceID string, value string, actionAttributeValues []*registeredresources.ActionAttributeValue, metadata *common.MetadataMutable) (*policy.RegisteredResourceValue, error) GetRegisteredResource(ctx context.Context, id, name, namespace string) (*policy.RegisteredResource, error) + DeleteAction(ctx context.Context, id string) error + DeleteSubjectConditionSet(ctx context.Context, id string) error + DeleteSubjectMapping(ctx context.Context, id string) (*policy.SubjectMapping, error) + DeleteRegisteredResource(ctx context.Context, id string) error + DeleteRegisteredResourceValue(ctx context.Context, id string) error + DeleteObligationTrigger(ctx context.Context, id string) (*policy.ObligationTrigger, error) } type Executor struct { diff --git a/otdfctl/migrations/namespacedpolicy/execute_test_helpers_test.go b/otdfctl/migrations/namespacedpolicy/execute_test_helpers_test.go index 5808a5ec11..2ee1840c3c 100644 --- a/otdfctl/migrations/namespacedpolicy/execute_test_helpers_test.go +++ b/otdfctl/migrations/namespacedpolicy/execute_test_helpers_test.go @@ -33,25 +33,38 @@ func wantError(is error, format string, args ...any) *expectedError { } type mockExecutorHandler struct { - created map[string]map[string]*createdActionCall - results map[string]map[string]*policy.Action // ! Should be renamed to actionResults - errs map[string]map[string]error - createdSubjectConditions map[string]map[string]*createdSubjectConditionSetCall - subjectConditionSetResult map[string]map[string]*policy.SubjectConditionSet - subjectConditionSetErrs map[string]map[string]error - createdSubjectMappings map[string]map[string]*createdSubjectMappingCall - subjectMappingResults map[string]map[string]*policy.SubjectMapping - subjectMappingErrs map[string]map[string]error - createdObligationTriggers map[string]map[string]*createdObligationTriggerCall - obligationTriggerResult map[string]map[string]*policy.ObligationTrigger - obligationTriggerErrs map[string]map[string]error - createdRegisteredResources map[string]map[string]*createdRegisteredResourceCall - registeredResourceResult map[string]map[string]*policy.RegisteredResource - registeredResourcesByID map[string]*policy.RegisteredResource - registeredResourceErrs map[string]map[string]error - createdRegisteredResourceValues map[string]map[string]*createdRegisteredResourceValueCall - registeredResourceValueResult map[string]map[string]*policy.RegisteredResourceValue - registeredResourceValueErrs map[string]map[string]error + created map[string]map[string]*createdActionCall + results map[string]map[string]*policy.Action // ! Should be renamed to actionResults + errs map[string]map[string]error + createdSubjectConditions map[string]map[string]*createdSubjectConditionSetCall + subjectConditionSetResult map[string]map[string]*policy.SubjectConditionSet + subjectConditionSetErrs map[string]map[string]error + createdSubjectMappings map[string]map[string]*createdSubjectMappingCall + subjectMappingResults map[string]map[string]*policy.SubjectMapping + subjectMappingErrs map[string]map[string]error + createdObligationTriggers map[string]map[string]*createdObligationTriggerCall + obligationTriggerResult map[string]map[string]*policy.ObligationTrigger + obligationTriggerErrs map[string]map[string]error + createdRegisteredResources map[string]map[string]*createdRegisteredResourceCall + registeredResourceResult map[string]map[string]*policy.RegisteredResource + registeredResourcesByID map[string]*policy.RegisteredResource + registeredResourceErrs map[string]map[string]error + createdRegisteredResourceValues map[string]map[string]*createdRegisteredResourceValueCall + registeredResourceValueResult map[string]map[string]*policy.RegisteredResourceValue + registeredResourceValueErrs map[string]map[string]error + deleteCalls []string + deletedActions []string + deleteActionErrs map[string]error + deletedSubjectConditionSets []string + deleteSubjectConditionSetErrs map[string]error + deletedSubjectMappings []string + deleteSubjectMappingErrs map[string]error + deletedRegisteredResources []string + deleteRegisteredResourceErrs map[string]error + deletedRegisteredResourceValues []string + deleteRegisteredResourceValueErrs map[string]error + deletedObligationTriggers []string + deleteObligationTriggerErrs map[string]error } type createdActionCall struct { @@ -296,3 +309,61 @@ func (m *mockExecutorHandler) CreateRegisteredResourceValue(_ context.Context, r return nil, errMissingMockRegisteredResourceValue } + +func (m *mockExecutorHandler) DeleteAction(_ context.Context, id string) error { + m.deleteCalls = append(m.deleteCalls, "action:"+id) + m.deletedActions = append(m.deletedActions, id) + if m.deleteActionErrs == nil { + return nil + } + return m.deleteActionErrs[id] +} + +func (m *mockExecutorHandler) DeleteSubjectConditionSet(_ context.Context, id string) error { + m.deleteCalls = append(m.deleteCalls, "subject-condition-set:"+id) + m.deletedSubjectConditionSets = append(m.deletedSubjectConditionSets, id) + if m.deleteSubjectConditionSetErrs == nil { + return nil + } + return m.deleteSubjectConditionSetErrs[id] +} + +func (m *mockExecutorHandler) DeleteSubjectMapping(_ context.Context, id string) (*policy.SubjectMapping, error) { + m.deleteCalls = append(m.deleteCalls, "subject-mapping:"+id) + m.deletedSubjectMappings = append(m.deletedSubjectMappings, id) + if m.deleteSubjectMappingErrs != nil { + if err := m.deleteSubjectMappingErrs[id]; err != nil { + return nil, err + } + } + return &policy.SubjectMapping{Id: id}, nil +} + +func (m *mockExecutorHandler) DeleteRegisteredResource(_ context.Context, id string) error { + m.deleteCalls = append(m.deleteCalls, "registered-resource:"+id) + m.deletedRegisteredResources = append(m.deletedRegisteredResources, id) + if m.deleteRegisteredResourceErrs == nil { + return nil + } + return m.deleteRegisteredResourceErrs[id] +} + +func (m *mockExecutorHandler) DeleteRegisteredResourceValue(_ context.Context, id string) error { + m.deleteCalls = append(m.deleteCalls, "registered-resource-value:"+id) + m.deletedRegisteredResourceValues = append(m.deletedRegisteredResourceValues, id) + if m.deleteRegisteredResourceValueErrs == nil { + return nil + } + return m.deleteRegisteredResourceValueErrs[id] +} + +func (m *mockExecutorHandler) DeleteObligationTrigger(_ context.Context, id string) (*policy.ObligationTrigger, error) { + m.deleteCalls = append(m.deleteCalls, "obligation-trigger:"+id) + m.deletedObligationTriggers = append(m.deletedObligationTriggers, id) + if m.deleteObligationTriggerErrs != nil { + if err := m.deleteObligationTriggerErrs[id]; err != nil { + return nil, err + } + } + return &policy.ObligationTrigger{Id: id}, nil +} diff --git a/otdfctl/migrations/namespacedpolicy/interactive_commit.go b/otdfctl/migrations/namespacedpolicy/interactive_commit.go index 5aebe86f09..521415bdfd 100644 --- a/otdfctl/migrations/namespacedpolicy/interactive_commit.go +++ b/otdfctl/migrations/namespacedpolicy/interactive_commit.go @@ -26,17 +26,21 @@ const ( backupAbortDetail = "Choose abort if you have not created a backup yet." backupConfirmLabel = "Yes, continue" backupCancelLabel = "Abort" - sourceIDText = "Source ID: " - actionText = "Action: " - actionsText = "Actions: " - resourceText = "Resource: " - targetNamespaceText = "Target namespace: " - attributeValueText = "Attribute value: " - obligationValueText = "Obligation value: " - valuesText = "Values: " - actionBindingsText = "Action bindings: " - subjectSetsTextFmt = "Subject sets: %d" - scsSourceText = "Subject condition set source: " + + //nolint:gosec // user-facing backup prompt text, not credentials + pruneBackupWarningTitle = "WARNING: This operation will prune migrated namespaced policy and permanently delete legacy policy objects." + pruneBackupConfirmDetail = "Commit mode will delete legacy/global policy objects from the target system." + sourceIDText = "Source ID: " + actionText = "Action: " + actionsText = "Actions: " + resourceText = "Resource: " + targetNamespaceText = "Target namespace: " + attributeValueText = "Attribute value: " + obligationValueText = "Obligation value: " + valuesText = "Values: " + actionBindingsText = "Action bindings: " + subjectSetsTextFmt = "Subject sets: %d" + scsSourceText = "Subject condition set source: " createActionDescription = "This will create a new namespaced action." createSubjectConditionSetDesc = "This will create a new namespaced subject condition set." @@ -55,18 +59,26 @@ const ( var ErrNamespacedPolicyBackupNotConfirmed = errors.New("user did not confirm backup") func ConfirmNamespacedPolicyBackup(ctx context.Context, prompter InteractivePrompter) error { + return confirmNamespacedPolicyBackup(ctx, prompter, backupWarningTitle, backupConfirmDetail) +} + +func ConfirmNamespacedPolicyPruneBackup(ctx context.Context, prompter InteractivePrompter) error { + return confirmNamespacedPolicyBackup(ctx, prompter, pruneBackupWarningTitle, pruneBackupConfirmDetail) +} + +func confirmNamespacedPolicyBackup(ctx context.Context, prompter InteractivePrompter, warningTitle, confirmDetail string) error { if prompter == nil { prompter = &HuhPrompter{} } styles := migrations.NewDisplayStyles() - fmt.Println(styles.Warning().Render(backupWarningTitle)) + fmt.Println(styles.Warning().Render(warningTitle)) fmt.Println(styles.Warning().Render(backupWarningBody)) err := prompter.Confirm(ctx, ConfirmPrompt{ Title: backupConfirmTitle, Description: []string{ - backupConfirmDetail, + confirmDetail, backupAbortDetail, }, ConfirmLabel: backupConfirmLabel, diff --git a/otdfctl/migrations/namespacedpolicy/interactive_commit_test.go b/otdfctl/migrations/namespacedpolicy/interactive_commit_test.go index ad999863a7..d092099883 100644 --- a/otdfctl/migrations/namespacedpolicy/interactive_commit_test.go +++ b/otdfctl/migrations/namespacedpolicy/interactive_commit_test.go @@ -28,6 +28,37 @@ func TestConfirmNamespacedPolicyBackupMapsAbortToBackupError(t *testing.T) { assert.Equal(t, backupCancelLabel, prompter.lastConfirmPrompt.CancelLabel) } +func TestConfirmNamespacedPolicyPruneBackupUsesPrunePrompt(t *testing.T) { + t.Parallel() + + prompter := &testInteractivePrompter{} + + err := ConfirmNamespacedPolicyPruneBackup(t.Context(), prompter) + require.NoError(t, err) + + require.Equal(t, 1, prompter.confirmCalls) + require.NotNil(t, prompter.lastConfirmPrompt) + assert.Equal(t, backupConfirmTitle, prompter.lastConfirmPrompt.Title) + assert.Equal(t, []string{pruneBackupConfirmDetail, backupAbortDetail}, prompter.lastConfirmPrompt.Description) + assert.Equal(t, backupConfirmLabel, prompter.lastConfirmPrompt.ConfirmLabel) + assert.Equal(t, backupCancelLabel, prompter.lastConfirmPrompt.CancelLabel) +} + +func TestConfirmNamespacedPolicyPruneBackupMapsAbortToBackupError(t *testing.T) { + t.Parallel() + + prompter := &testInteractivePrompter{ + confirmErr: ErrInteractiveReviewAborted, + } + + err := ConfirmNamespacedPolicyPruneBackup(t.Context(), prompter) + require.ErrorIs(t, err, ErrNamespacedPolicyBackupNotConfirmed) + + require.Equal(t, 1, prompter.confirmCalls) + require.NotNil(t, prompter.lastConfirmPrompt) + assert.Equal(t, []string{pruneBackupConfirmDetail, backupAbortDetail}, prompter.lastConfirmPrompt.Description) +} + func TestReviewNamespacedPolicyInteractiveCommitSkipsDependentsOfSkippedAction(t *testing.T) { t.Parallel() diff --git a/otdfctl/migrations/namespacedpolicy/prune_execute.go b/otdfctl/migrations/namespacedpolicy/prune_execute.go new file mode 100644 index 0000000000..1ad45f1db2 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/prune_execute.go @@ -0,0 +1,118 @@ +package namespacedpolicy + +import ( + "context" + "fmt" +) + +type ( + pruneDeleteFunc[T prunePlanItem] func(context.Context, T, string) error +) + +func (e *Executor) ExecutePrune(ctx context.Context, plan *PrunePlan) error { + if err := e.validatePrunePlan(plan); err != nil { + return err + } + + switch plan.Scopes[0] { + case ScopeObligationTriggers: + return e.executePruneObligationTriggers(ctx, plan.ObligationTriggers) + case ScopeSubjectMappings: + return e.executePruneSubjectMappings(ctx, plan.SubjectMappings) + case ScopeRegisteredResources: + return e.executePruneRegisteredResources(ctx, plan.RegisteredResources) + case ScopeSubjectConditionSets: + return e.executePruneSubjectConditionSets(ctx, plan.SubjectConditionSets) + case ScopeActions: + return e.executePruneActions(ctx, plan.Actions) + default: + return fmt.Errorf("%w: %s", ErrInvalidScope, plan.Scopes[0]) + } +} + +func (e *Executor) validatePrunePlan(plan *PrunePlan) error { + if e == nil || e.handler == nil { + return ErrNilExecutorHandler + } + if plan == nil { + return ErrNilExecutionPlan + } + if len(plan.Scopes) == 0 { + return ErrEmptyPlannerScope + } + if len(plan.Scopes) != 1 { + return ErrMultiplePruneScopes + } + + return nil +} + +func (e *Executor) executePruneActions(ctx context.Context, plans []*PruneActionPlan) error { + return executePruneItems(ctx, e, plans, "action", func(ctx context.Context, _ *PruneActionPlan, sourceID string) error { + return e.handler.DeleteAction(ctx, sourceID) + }) +} + +func (e *Executor) executePruneSubjectConditionSets(ctx context.Context, plans []*PruneSubjectConditionSetPlan) error { + return executePruneItems(ctx, e, plans, "subject condition set", func(ctx context.Context, _ *PruneSubjectConditionSetPlan, sourceID string) error { + return e.handler.DeleteSubjectConditionSet(ctx, sourceID) + }) +} + +func (e *Executor) executePruneSubjectMappings(ctx context.Context, plans []*PruneSubjectMappingPlan) error { + return executePruneItems(ctx, e, plans, "subject mapping", func(ctx context.Context, _ *PruneSubjectMappingPlan, sourceID string) error { + _, err := e.handler.DeleteSubjectMapping(ctx, sourceID) + return err + }) +} + +func (e *Executor) executePruneRegisteredResources(ctx context.Context, plans []*PruneRegisteredResourcePlan) error { + return executePruneItems(ctx, e, plans, "registered resource", func(ctx context.Context, _ *PruneRegisteredResourcePlan, sourceID string) error { + return e.handler.DeleteRegisteredResource(ctx, sourceID) + }) +} + +func (e *Executor) executePruneObligationTriggers(ctx context.Context, plans []*PruneObligationTriggerPlan) error { + return executePruneItems(ctx, e, plans, "obligation trigger", func(ctx context.Context, _ *PruneObligationTriggerPlan, sourceID string) error { + _, err := e.handler.DeleteObligationTrigger(ctx, sourceID) + return err + }) +} + +func executePruneItems[T prunePlanItem]( + ctx context.Context, + executor *Executor, + items []T, + kind string, + deleteSource pruneDeleteFunc[T], +) error { + for _, item := range items { + if item.status() != PruneStatusDelete { + continue + } + + id := item.sourceID() + if id == "" { + return executor.recordPruneFailure(item, fmt.Errorf("%w: %s", ErrMissingPruneSourceID, kind)) + } + + if err := deleteSource(ctx, item, id); err != nil { + return executor.recordPruneFailure(item, fmt.Errorf("delete %s %q: %w", kind, id, err)) + } + + item.setExecution(&ExecutionResult{ + RunID: executor.runID, + Applied: true, + }) + } + + return nil +} + +func (e *Executor) recordPruneFailure(item prunePlanItem, err error) error { + item.setExecution(&ExecutionResult{ + RunID: e.runID, + Failure: err.Error(), + }) + return err +} diff --git a/otdfctl/migrations/namespacedpolicy/prune_execute_test.go b/otdfctl/migrations/namespacedpolicy/prune_execute_test.go new file mode 100644 index 0000000000..0a97d13ba5 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/prune_execute_test.go @@ -0,0 +1,194 @@ +package namespacedpolicy + +import ( + "errors" + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecutePruneDispatchesOnlyPlanScope(t *testing.T) { + tests := []struct { + name string + scope Scope + wantCalls []string + verify func(*testing.T, *PrunePlan) + }{ + { + name: "actions", + scope: ScopeActions, + wantCalls: []string{"action:action-delete-1", "action:action-delete-2"}, + verify: verifyPruneActionsExecuted, + }, + { + name: "subject condition sets", + scope: ScopeSubjectConditionSets, + wantCalls: []string{"subject-condition-set:scs-delete-1", "subject-condition-set:scs-delete-2"}, + verify: verifyPruneSubjectConditionSetsExecuted, + }, + { + name: "subject mappings", + scope: ScopeSubjectMappings, + wantCalls: []string{"subject-mapping:mapping-delete-1", "subject-mapping:mapping-delete-2"}, + verify: verifyPruneSubjectMappingsExecuted, + }, + { + name: "registered resources", + scope: ScopeRegisteredResources, + wantCalls: []string{"registered-resource:resource-delete-1", "registered-resource:resource-delete-2"}, + verify: verifyPruneRegisteredResourcesExecuted, + }, + { + name: "obligation triggers", + scope: ScopeObligationTriggers, + wantCalls: []string{"obligation-trigger:trigger-delete-1", "obligation-trigger:trigger-delete-2"}, + verify: verifyPruneObligationTriggersExecuted, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := &mockExecutorHandler{} + plan := mixedPrunePlan(tt.scope) + + executor, err := NewExecutor(handler) + require.NoError(t, err) + + err = executor.ExecutePrune(t.Context(), plan) + require.NoError(t, err) + + assert.Equal(t, tt.wantCalls, handler.deleteCalls) + tt.verify(t, plan) + }) + } +} + +func TestExecutePruneRecordsFailureAndStops(t *testing.T) { + deleteErr := errors.New("delete denied") + handler := &mockExecutorHandler{ + deleteActionErrs: map[string]error{ + "action-delete": deleteErr, + }, + } + plan := &PrunePlan{ + Scopes: []Scope{ScopeActions}, + Actions: []*PruneActionPlan{ + {Source: &policy.Action{Id: "action-delete"}, Status: PruneStatusDelete}, + {Source: &policy.Action{Id: "action-pending"}, Status: PruneStatusDelete}, + }, + } + + executor, err := NewExecutor(handler) + require.NoError(t, err) + + err = executor.ExecutePrune(t.Context(), plan) + require.ErrorIs(t, err, deleteErr) + require.EqualError(t, err, `delete action "action-delete": delete denied`) + + require.NotNil(t, plan.Actions[0].Execution) + assert.False(t, plan.Actions[0].Execution.Applied) + assert.Equal(t, `delete action "action-delete": delete denied`, plan.Actions[0].Execution.Failure) + assert.Nil(t, plan.Actions[1].Execution) + assert.Equal(t, []string{"action:action-delete"}, handler.deleteCalls) +} + +func TestExecutePruneRequiresSingleScope(t *testing.T) { + handler := &mockExecutorHandler{} + executor, err := NewExecutor(handler) + require.NoError(t, err) + + err = executor.ExecutePrune(t.Context(), &PrunePlan{}) + require.ErrorIs(t, err, ErrEmptyPlannerScope) + + err = executor.ExecutePrune(t.Context(), &PrunePlan{ + Scopes: []Scope{ScopeActions, ScopeRegisteredResources}, + }) + require.ErrorIs(t, err, ErrMultiplePruneScopes) +} + +func verifyPruneActionsExecuted(t *testing.T, plan *PrunePlan) { + t.Helper() + + assert.True(t, plan.Actions[0].Execution.Applied) + assert.True(t, plan.Actions[1].Execution.Applied) + assert.Nil(t, plan.Actions[2].Execution) +} + +func verifyPruneSubjectConditionSetsExecuted(t *testing.T, plan *PrunePlan) { + t.Helper() + + assert.True(t, plan.SubjectConditionSets[0].Execution.Applied) + assert.True(t, plan.SubjectConditionSets[1].Execution.Applied) + assert.Nil(t, plan.SubjectConditionSets[2].Execution) +} + +func verifyPruneSubjectMappingsExecuted(t *testing.T, plan *PrunePlan) { + t.Helper() + + assert.True(t, plan.SubjectMappings[0].Execution.Applied) + assert.True(t, plan.SubjectMappings[1].Execution.Applied) +} + +func verifyPruneRegisteredResourcesExecuted(t *testing.T, plan *PrunePlan) { + t.Helper() + + assert.True(t, plan.RegisteredResources[0].Execution.Applied) + assert.True(t, plan.RegisteredResources[1].Execution.Applied) +} + +func verifyPruneObligationTriggersExecuted(t *testing.T, plan *PrunePlan) { + t.Helper() + + assert.True(t, plan.ObligationTriggers[0].Execution.Applied) + assert.True(t, plan.ObligationTriggers[1].Execution.Applied) +} + +func mixedPrunePlan(scope Scope) *PrunePlan { + return &PrunePlan{ + Scopes: []Scope{scope}, + Actions: []*PruneActionPlan{ + {Source: &policy.Action{Id: "action-delete-1"}, Status: PruneStatusDelete}, + {Source: &policy.Action{Id: "action-delete-2"}, Status: PruneStatusDelete}, + {Source: &policy.Action{Id: "action-blocked"}, Status: PruneStatusBlocked}, + }, + SubjectConditionSets: []*PruneSubjectConditionSetPlan{ + {Source: &policy.SubjectConditionSet{Id: "scs-delete-1"}, Status: PruneStatusDelete}, + {Source: &policy.SubjectConditionSet{Id: "scs-delete-2"}, Status: PruneStatusDelete}, + {Source: &policy.SubjectConditionSet{Id: "scs-unresolved"}, Status: PruneStatusUnresolved}, + }, + SubjectMappings: []*PruneSubjectMappingPlan{ + {Source: &policy.SubjectMapping{Id: "mapping-delete-1"}, Status: PruneStatusDelete}, + {Source: &policy.SubjectMapping{Id: "mapping-delete-2"}, Status: PruneStatusDelete}, + }, + RegisteredResources: []*PruneRegisteredResourcePlan{ + { + Source: &policy.RegisteredResource{Id: "resource-delete-1"}, + FullSource: &policy.RegisteredResource{ + Id: "resource-delete-1", + Values: []*policy.RegisteredResourceValue{ + {Id: "value-1"}, + {Id: "value-2"}, + }, + }, + Status: PruneStatusDelete, + }, + { + Source: &policy.RegisteredResource{Id: "resource-delete-2"}, + FullSource: &policy.RegisteredResource{ + Id: "resource-delete-2", + Values: []*policy.RegisteredResourceValue{ + {Id: "value-3"}, + {Id: "value-4"}, + }, + }, + Status: PruneStatusDelete, + }, + }, + ObligationTriggers: []*PruneObligationTriggerPlan{ + {Source: &policy.ObligationTrigger{Id: "trigger-delete-1"}, Status: PruneStatusDelete}, + {Source: &policy.ObligationTrigger{Id: "trigger-delete-2"}, Status: PruneStatusDelete}, + }, + } +} diff --git a/otdfctl/migrations/namespacedpolicy/prune_plan.go b/otdfctl/migrations/namespacedpolicy/prune_plan.go index b7160195ed..583d72e7ea 100644 --- a/otdfctl/migrations/namespacedpolicy/prune_plan.go +++ b/otdfctl/migrations/namespacedpolicy/prune_plan.go @@ -112,11 +112,13 @@ type PrunePlan struct { type prunePlanItem interface { hasSource() bool + sourceID() string status() PruneStatus setStatus(PruneStatus) reason() PruneStatusReason setReason(PruneStatusReason) execution() *ExecutionResult + setExecution(*ExecutionResult) } // PruneActionPlan records the source action being considered for deletion and @@ -129,6 +131,13 @@ type PruneActionPlan struct { Execution *ExecutionResult `json:"execution,omitempty"` // The CreatedTargetID is not used for the PrunePlans. } +func (p *PruneActionPlan) sourceID() string { + if !p.hasSource() { + return "" + } + return p.Source.GetId() +} + func (p *PruneActionPlan) hasSource() bool { return p != nil && p.Source != nil } @@ -166,6 +175,12 @@ func (p *PruneActionPlan) execution() *ExecutionResult { return p.Execution } +func (p *PruneActionPlan) setExecution(execution *ExecutionResult) { + if p != nil { + p.Execution = execution + } +} + // PruneSubjectConditionSetPlan records the source SCS being considered for // deletion and any migrated target subject condition sets that still reference // or replace it. @@ -177,6 +192,13 @@ type PruneSubjectConditionSetPlan struct { Execution *ExecutionResult `json:"execution,omitempty"` } +func (p *PruneSubjectConditionSetPlan) sourceID() string { + if !p.hasSource() { + return "" + } + return p.Source.GetId() +} + func (p *PruneSubjectConditionSetPlan) hasSource() bool { return p != nil && p.Source != nil } @@ -214,6 +236,12 @@ func (p *PruneSubjectConditionSetPlan) execution() *ExecutionResult { return p.Execution } +func (p *PruneSubjectConditionSetPlan) setExecution(execution *ExecutionResult) { + if p != nil { + p.Execution = execution + } +} + // PruneSubjectMappingPlan records the source subject mapping being considered // for deletion and the single migrated target subject mapping matched to it by // migration metadata. @@ -225,6 +253,13 @@ type PruneSubjectMappingPlan struct { Execution *ExecutionResult `json:"execution,omitempty"` } +func (p *PruneSubjectMappingPlan) sourceID() string { + if !p.hasSource() { + return "" + } + return p.Source.GetId() +} + func (p *PruneSubjectMappingPlan) hasSource() bool { return p != nil && p.Source != nil } @@ -262,6 +297,12 @@ func (p *PruneSubjectMappingPlan) execution() *ExecutionResult { return p.Execution } +func (p *PruneSubjectMappingPlan) setExecution(execution *ExecutionResult) { + if p != nil { + p.Execution = execution + } +} + // PruneRegisteredResourcePlan records the resolved RR source being considered // for deletion and the single migrated target RR matched to it by migration // metadata. @@ -276,6 +317,13 @@ type PruneRegisteredResourcePlan struct { Execution *ExecutionResult `json:"execution,omitempty"` } +func (p *PruneRegisteredResourcePlan) sourceID() string { + if !p.hasSource() { + return "" + } + return p.Source.GetId() +} + func (p *PruneRegisteredResourcePlan) hasSource() bool { return p != nil && p.Source != nil } @@ -313,6 +361,12 @@ func (p *PruneRegisteredResourcePlan) execution() *ExecutionResult { return p.Execution } +func (p *PruneRegisteredResourcePlan) setExecution(execution *ExecutionResult) { + if p != nil { + p.Execution = execution + } +} + // PruneObligationTriggerPlan records the source obligation trigger being // considered for deletion and the single migrated target obligation trigger // matched to it by migration metadata. @@ -324,6 +378,13 @@ type PruneObligationTriggerPlan struct { Execution *ExecutionResult `json:"execution,omitempty"` } +func (p *PruneObligationTriggerPlan) sourceID() string { + if !p.hasSource() { + return "" + } + return p.Source.GetId() +} + func (p *PruneObligationTriggerPlan) hasSource() bool { return p != nil && p.Source != nil } @@ -360,3 +421,9 @@ func (p *PruneObligationTriggerPlan) execution() *ExecutionResult { } return p.Execution } + +func (p *PruneObligationTriggerPlan) setExecution(execution *ExecutionResult) { + if p != nil { + p.Execution = execution + } +} diff --git a/otdfctl/migrations/namespacedpolicy/prune_plan_test.go b/otdfctl/migrations/namespacedpolicy/prune_plan_test.go index bf0c1c9c90..281f40aed2 100644 --- a/otdfctl/migrations/namespacedpolicy/prune_plan_test.go +++ b/otdfctl/migrations/namespacedpolicy/prune_plan_test.go @@ -3,6 +3,7 @@ package namespacedpolicy import ( "testing" + "github.com/opentdf/platform/protocol/go/policy" "github.com/stretchr/testify/assert" ) @@ -55,3 +56,96 @@ func TestTargetRefString(t *testing.T) { }) } } + +func TestPrunePlanItemSourceID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + item prunePlanItem + want string + }{ + { + name: "action", + item: &PruneActionPlan{Source: &policy.Action{Id: "action-1"}}, + want: "action-1", + }, + { + name: "action without source", + item: &PruneActionPlan{}, + want: "", + }, + { + name: "nil action plan", + item: (*PruneActionPlan)(nil), + want: "", + }, + { + name: "subject condition set", + item: &PruneSubjectConditionSetPlan{Source: &policy.SubjectConditionSet{Id: "scs-1"}}, + want: "scs-1", + }, + { + name: "subject condition set without source", + item: &PruneSubjectConditionSetPlan{}, + want: "", + }, + { + name: "nil subject condition set plan", + item: (*PruneSubjectConditionSetPlan)(nil), + want: "", + }, + { + name: "subject mapping", + item: &PruneSubjectMappingPlan{Source: &policy.SubjectMapping{Id: "mapping-1"}}, + want: "mapping-1", + }, + { + name: "subject mapping without source", + item: &PruneSubjectMappingPlan{}, + want: "", + }, + { + name: "nil subject mapping plan", + item: (*PruneSubjectMappingPlan)(nil), + want: "", + }, + { + name: "registered resource", + item: &PruneRegisteredResourcePlan{Source: &policy.RegisteredResource{Id: "resource-1"}}, + want: "resource-1", + }, + { + name: "registered resource without source", + item: &PruneRegisteredResourcePlan{}, + want: "", + }, + { + name: "nil registered resource plan", + item: (*PruneRegisteredResourcePlan)(nil), + want: "", + }, + { + name: "obligation trigger", + item: &PruneObligationTriggerPlan{Source: &policy.ObligationTrigger{Id: "trigger-1"}}, + want: "trigger-1", + }, + { + name: "obligation trigger without source", + item: &PruneObligationTriggerPlan{}, + want: "", + }, + { + name: "nil obligation trigger plan", + item: (*PruneObligationTriggerPlan)(nil), + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, tc.item.sourceID()) + }) + } +}