From e8daf70a2c38abf45549a7332e24e98746a7585a Mon Sep 17 00:00:00 2001 From: Chris Reed Date: Mon, 18 May 2026 10:09:00 -0500 Subject: [PATCH 1/3] chore(cli): Add E2E tests / doc cleanup. --- otdfctl/docs/man/migrate/namespaced-policy.md | 2 +- otdfctl/docs/man/migrate/prune/_index.md | 9 - .../man/migrate/prune/namespaced-policy.md | 48 +- otdfctl/e2e/migrate-namespaced-policy.bats | 833 +++++++++++++++++- 4 files changed, 874 insertions(+), 18 deletions(-) diff --git a/otdfctl/docs/man/migrate/namespaced-policy.md b/otdfctl/docs/man/migrate/namespaced-policy.md index a85d02dfe8..8c0cd5ef6a 100644 --- a/otdfctl/docs/man/migrate/namespaced-policy.md +++ b/otdfctl/docs/man/migrate/namespaced-policy.md @@ -22,7 +22,7 @@ Commit mode can partially apply changes before an error occurs. When that happen The parent `migrate` command provides the shared `--commit` and `--interactive` flags. -`namespaced-policy` is intended to be non-destructive. Commit should create namespaced copies and record migration metadata, but it should not delete legacy objects. Cleanup belongs to `migrate prune`. +`namespaced-policy` is intended to be non-destructive. Commit should create namespaced copies and record migration metadata, but it should not delete legacy objects. Cleanup belongs to `migrate prune namespaced-policy`. All target namespaces must already exist before the command runs. Planning should fail before any writes if a required namespace is missing. diff --git a/otdfctl/docs/man/migrate/prune/_index.md b/otdfctl/docs/man/migrate/prune/_index.md index 145ba179d8..f458e1baa1 100644 --- a/otdfctl/docs/man/migrate/prune/_index.md +++ b/otdfctl/docs/man/migrate/prune/_index.md @@ -7,17 +7,8 @@ command: `prune` groups commands used to remove policy resources that are no longer needed after migration or cleanup workflows. -The end-to-end cleanup workflow is not implemented yet, but the command surface is in place. - Available subcommands currently include `namespaced-policy` for policy cleanup workflows. The parent `migrate` command provides the shared `--commit` flag used to apply deletions. `migrate prune` is not the same as `otdfctl policy subject-condition-sets prune`. The existing subject-condition-set prune command deletes unmapped subject condition sets. `migrate prune` is only for cleaning up legacy objects after a migration run. - -## Planned examples - -```shell -otdfctl migrate prune namespaced-policy --scope=registered-resources -otdfctl migrate prune namespaced-policy --scope=actions,subject-mappings,registered-resources --commit -``` diff --git a/otdfctl/docs/man/migrate/prune/namespaced-policy.md b/otdfctl/docs/man/migrate/prune/namespaced-policy.md index d7d4875716..16ab541fb4 100644 --- a/otdfctl/docs/man/migrate/prune/namespaced-policy.md +++ b/otdfctl/docs/man/migrate/prune/namespaced-policy.md @@ -6,23 +6,61 @@ command: flags: - name: scope shorthand: s - description: "Comma-separated scopes: actions, subject-condition-sets, subject-mappings, registered-resources, obligation-triggers" + description: "One scope to prune: actions, subject-condition-sets, subject-mappings, registered-resources, obligation-triggers" default: '' --- +## General Information + `namespaced-policy` is the cleanup entrypoint for namespaced policy migration. -The command surface is present, but the cleanup workflow is not implemented yet. +The command prints a human-readable prune summary to stdout. Dry runs show the planned deletions and blocked items; `--commit` shows the committed summary with the objects that were deleted. -`--scope` is required and selects any subset of `actions`, `subject-condition-sets`, `subject-mappings`, `registered-resources`, and `obligation-triggers`. +`--scope` is required and must be exactly one of `actions`, `subject-condition-sets`, `subject-mappings`, `registered-resources`, or `obligation-triggers`. -`namespaced-policy` rebuilds the live dependency graph, inspects migration labels, and deletes only legacy objects it can prove are safe to remove for the selected scopes. It does not require a manifest file. +`namespaced-policy` rebuilds the live dependency graph, inspects migration labels, and deletes only legacy objects it can prove are safe to remove for the selected scope. The parent `migrate` command provides the shared `--commit` flag used to apply deletions. +## Pre-requisites + +1. Run at least `v0.14.0` of the OpenTDF platform before using this prune flow. + +2. Run `otdfctl migrate namespaced-policy` successfully before pruning. Prune only deletes legacy objects after it can match them to the expected migrated targets and their `migrated_from` labels. + +## Delete safety + +An object is safe to delete only when prune can tie the legacy source object to the expected migrated target and prove the source is no longer needed. + +- `delete`: the source has the expected migrated target and prune found no remaining legacy dependency that still requires the source object. +- `blocked`: prune will not delete the source. Common reasons are that the source is still referenced by legacy policy or that the source object has not actually been migrated yet. +- `unresolved`: prune found something close to a migrated target, but it cannot prove the source and target match safely. Common reasons are missing or mismatched `migrated_from` labels, no matching labeled target, or a registered resource source that still contains values outside the resolved migration view. + +In practice, prune relies on current legacy references plus `migrated_from` metadata on the namespaced targets. If that evidence is incomplete or inconsistent, the object is left in place instead of being deleted. + +## Best practices + +1. Before running any prune commands you should take a backup of your database to avoid any potential issues. + +2. Turn on the `namespaced_policy` feature flag within your deployed service yaml to avoid creating any accidental non-namespaced policy objects. + +3. Prune one scope at a time. + +4. We recommend pruning in the reverse order of migration so dependents are removed before their dependencies: + - Registered-Resources + - Obligation-Triggers + - Subject-Mappings + - Subject-Condition-Sets + - Actions + ## Examples ```shell otdfctl migrate prune namespaced-policy --scope=registered-resources -otdfctl migrate prune namespaced-policy --scope=actions,subject-mappings,registered-resources --commit +otdfctl migrate prune namespaced-policy --scope=obligation-triggers --commit +otdfctl migrate prune namespaced-policy --scope=obligation-triggers --interactive --commit ``` + +## Other Information + +1. Actions and subject-condition-sets are pruned a little differently from the other scopes. Instead of reusing the resolved migration view, prune classifies them directly from the current legacy objects, their current legacy references, and the canonical migrated targets it can find. We do that because actions and subject-condition-sets are expected to be pruned last. By the time you reach those scopes, their legacy dependents such as subject mappings, registered resources, and obligation triggers should already be gone, so the safest decision comes from checking the live legacy dependency graph at prune time. That also means some actions or subject-condition-sets that were never used by any other legacy policy object can still end up `blocked`. If prune cannot find a canonical migrated target for the source object, it leaves the source in place as `blocked` instead of assuming it is safe to delete. For example, if a custom action `decrypt` is no longer referenced by any legacy subject mapping, registered resource, or obligation trigger and prune finds a namespaced `decrypt` target with `metadata.labels.migrated_from=`, the source action is safe to delete. If no canonical namespaced `decrypt` target exists, the source action is reported as `blocked` because prune cannot prove that the object was actually migrated. diff --git a/otdfctl/e2e/migrate-namespaced-policy.bats b/otdfctl/e2e/migrate-namespaced-policy.bats index f767d5f43f..e9ac90c91b 100644 --- a/otdfctl/e2e/migrate-namespaced-policy.bats +++ b/otdfctl/e2e/migrate-namespaced-policy.bats @@ -155,7 +155,52 @@ track_obligation_trigger_id() { TRACKED_OBLIGATION_TRIGGER_IDS="${TRACKED_OBLIGATION_TRIGGER_IDS}${obligation_trigger_id}"$'\n' } -create_global_action() { +remove_tracked_id() { + local remove_id="$1" + local tracked_ids="$2" + local remaining_ids="" + local tracked_id + + while IFS= read -r tracked_id; do + [ -n "$tracked_id" ] || continue + [ "$tracked_id" != "$remove_id" ] || continue + remaining_ids="${remaining_ids}${tracked_id}"$'\n' + done <<< "$tracked_ids" + + printf '%s' "$remaining_ids" +} + +untrack_action_id() { + local action_id="$1" + TRACKED_ACTION_IDS="$(remove_tracked_id "$action_id" "$TRACKED_ACTION_IDS")" +} + +untrack_registered_resource_id() { + local resource_id="$1" + TRACKED_REGISTERED_RESOURCE_IDS="$(remove_tracked_id "$resource_id" "$TRACKED_REGISTERED_RESOURCE_IDS")" +} + +untrack_registered_resource_value_id() { + local resource_value_id="$1" + TRACKED_REGISTERED_RESOURCE_VALUE_IDS="$(remove_tracked_id "$resource_value_id" "$TRACKED_REGISTERED_RESOURCE_VALUE_IDS")" +} + +untrack_scs_id() { + local scs_id="$1" + TRACKED_SCS_IDS="$(remove_tracked_id "$scs_id" "$TRACKED_SCS_IDS")" +} + +untrack_subject_mapping_id() { + local subject_mapping_id="$1" + TRACKED_SUBJECT_MAPPING_IDS="$(remove_tracked_id "$subject_mapping_id" "$TRACKED_SUBJECT_MAPPING_IDS")" +} + +untrack_obligation_trigger_id() { + local obligation_trigger_id="$1" + TRACKED_OBLIGATION_TRIGGER_IDS="$(remove_tracked_id "$obligation_trigger_id" "$TRACKED_OBLIGATION_TRIGGER_IDS")" +} + +create_action() { local result_var="$1" local action_name="$2" shift 2 @@ -171,6 +216,19 @@ create_global_action() { printf -v "$result_var" '%s' "$created_action_id" } +create_global_action() { + create_action "$@" +} + +create_namespaced_action() { + local result_var="$1" + local namespace_id="$2" + local action_name="$3" + shift 3 + + create_action "$result_var" "$action_name" --namespace "$namespace_id" "$@" +} + create_global_scs() { local result_var="$1" local subject_sets_json="$2" @@ -257,6 +315,23 @@ create_global_registered_resource() { printf -v "$result_var" '%s' "$created_resource_id" } +create_namespaced_registered_resource() { + local result_var="$1" + local namespace_id="$2" + local resource_name="$3" + shift 3 + + run_otdfctl_registered_resources create --name "$resource_name" --namespace "$namespace_id" "$@" --json + assert_success + + local created_resource_id + created_resource_id=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$created_resource_id" "" + + track_registered_resource_id "$created_resource_id" + printf -v "$result_var" '%s' "$created_resource_id" +} + create_registered_resource_value() { local result_var="$1" local resource_id="$2" @@ -590,6 +665,27 @@ assert_legacy_subject_mapping_still_exists() { assert_equal "$(echo "$legacy_mapping_json" | jq -r '.attribute_value.id')" "$attribute_value_id" } +assert_legacy_subject_mapping_pruned() { + local source_mapping_id="$1" + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy subject-mappings get --id "$source_mapping_id" --json + assert_failure +} + +assert_subject_mapping_target_still_exists() { + local target_mapping_id="$1" + local namespace_id="$2" + local source_mapping_id="$3" + + local target_mapping_json + run_otdfctl_sm get --id "$target_mapping_id" --json + target_mapping_json="$output" + + assert_equal "$(echo "$target_mapping_json" | jq -r '.id // empty')" "$target_mapping_id" + assert_equal "$(echo "$target_mapping_json" | jq -r '.namespace.id')" "$namespace_id" + assert_equal "$(echo "$target_mapping_json" | jq -r '.metadata.labels.migrated_from')" "$source_mapping_id" +} + assert_no_subject_mappings_in_namespace() { local namespace_id="$1" local namespace_state @@ -888,6 +984,13 @@ assert_legacy_custom_action_still_exists() { assert_equal "$(echo "$legacy_action_json" | jq -r '.namespace.id // empty')" "" } +assert_legacy_custom_action_pruned() { + local action_id="$1" + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy actions get --id "$action_id" --json + assert_failure +} + assert_scs_created_in_namespace() { local source_scs_id="$1" local namespace_id="$2" @@ -1034,6 +1137,70 @@ assert_legacy_registered_resource_still_exists() { assert_equal "$(echo "$legacy_resource_value_json" | jq -r '.value')" "$resource_value" } +assert_legacy_registered_resource_pruned() { + local source_resource_id="$1" + local source_value_id="$2" + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy registered-resources get --id "$source_resource_id" --json + assert_failure + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy registered-resources values get --id "$source_value_id" --json + assert_failure +} + +assert_registered_resource_target_still_exists() { + local target_resource_id="$1" + local target_value_id="$2" + local namespace_id="$3" + local source_resource_id="$4" + local source_value_id="$5" + + local target_resource_json + run_otdfctl_registered_resources get --id "$target_resource_id" --json + target_resource_json="$output" + + assert_equal "$(echo "$target_resource_json" | jq -r '.id // empty')" "$target_resource_id" + assert_equal "$(echo "$target_resource_json" | jq -r '.namespace.id')" "$namespace_id" + assert_equal "$(echo "$target_resource_json" | jq -r '.metadata.labels.migrated_from')" "$source_resource_id" + + local target_value_json + run_otdfctl_registered_resource_values get --id "$target_value_id" --json + target_value_json="$output" + + assert_equal "$(echo "$target_value_json" | jq -r '.id // empty')" "$target_value_id" + assert_equal "$(echo "$target_value_json" | jq -r '.metadata.labels.migrated_from')" "$source_value_id" +} + +assert_registered_resource_unlabeled_target_still_exists() { + local target_resource_id="$1" + local target_value_id="$2" + local namespace_id="$3" + local resource_name="$4" + local resource_value="$5" + local action_id="$6" + local attribute_value_id="$7" + + local target_resource_json + run_otdfctl_registered_resources get --id "$target_resource_id" --json + target_resource_json="$output" + + assert_equal "$(echo "$target_resource_json" | jq -r '.id // empty')" "$target_resource_id" + assert_equal "$(echo "$target_resource_json" | jq -r '.namespace.id')" "$namespace_id" + assert_equal "$(echo "$target_resource_json" | jq -r '.name')" "$resource_name" + assert_equal "$(echo "$target_resource_json" | jq -r '.metadata.labels.migrated_from // empty')" "" + + local target_value_json + run_otdfctl_registered_resource_values get --id "$target_value_id" --json + target_value_json="$output" + + assert_equal "$(echo "$target_value_json" | jq -r '.id // empty')" "$target_value_id" + assert_equal "$(echo "$target_value_json" | jq -r '.value')" "$resource_value" + assert_equal "$(echo "$target_value_json" | jq -r '.action_attribute_values | length')" "1" + assert_equal "$(echo "$target_value_json" | jq -r '.action_attribute_values[0].action.id')" "$action_id" + assert_equal "$(echo "$target_value_json" | jq -r '.action_attribute_values[0].attribute_value.id')" "$attribute_value_id" + assert_equal "$(echo "$target_value_json" | jq -r '.metadata.labels.migrated_from // empty')" "" +} + assert_obligation_trigger_created_in_namespace() { local source_trigger_id="$1" local namespace_id="$2" @@ -1134,6 +1301,29 @@ assert_legacy_obligation_trigger_still_exists() { assert_equal "$(echo "$legacy_trigger_json" | jq -r '.context[0].pep.client_id')" "$client_id" } +assert_legacy_obligation_trigger_pruned() { + local source_trigger_id="$1" + local namespace_id="$2" + local triggers_json + + run_otdfctl_obligation_triggers list --namespace "$namespace_id" --limit 100 --offset 0 --json + triggers_json="$output" + + assert_equal "$(echo "$triggers_json" | jq -r --arg source_trigger_id "$source_trigger_id" '[(.triggers // [])[] | select(.id == $source_trigger_id)] | length')" "0" +} + +assert_obligation_trigger_target_still_exists() { + local target_trigger_id="$1" + local namespace_id="$2" + local source_trigger_id="$3" + + local target_trigger_json + target_trigger_json=$(obligation_trigger_json_by_id "$target_trigger_id" "$namespace_id") + + assert_equal "$(echo "$target_trigger_json" | jq -r '.id // empty')" "$target_trigger_id" + assert_equal "$(echo "$target_trigger_json" | jq -r '.metadata.labels.migrated_from')" "$source_trigger_id" +} + assert_scs_already_migrated_in_namespace() { local source_scs_id="$1" local namespace_id="$2" @@ -1163,12 +1353,39 @@ assert_legacy_scs_still_exists() { assert_equal "$(echo "$legacy_scs_json" | jq -r '.namespace.id // empty')" "" } +assert_legacy_scs_pruned() { + local source_scs_id="$1" + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy scs get --id "$source_scs_id" --json + assert_failure +} + +assert_scs_target_still_exists() { + local target_scs_id="$1" + local namespace_id="$2" + local source_scs_id="$3" + + local target_scs_json + run_otdfctl_scs get --id "$target_scs_id" --json + target_scs_json="$output" + + assert_equal "$(echo "$target_scs_json" | jq -r '.id // empty')" "$target_scs_id" + assert_equal "$(echo "$target_scs_json" | jq -r '.namespace.id')" "$namespace_id" + assert_equal "$(echo "$target_scs_json" | jq -r '.metadata.labels.migrated_from')" "$source_scs_id" +} + run_namespaced_policy_commit() { local scope="$1" run_otdfctl_migrate --commit namespaced-policy --scope "$scope" } +run_namespaced_policy_prune_commit() { + local scope="$1" + + run_otdfctl_migrate --commit prune namespaced-policy --scope "$scope" +} + setup() { bats_load_library bats-support bats_load_library bats-assert @@ -1773,9 +1990,7 @@ teardown_file() { local obligation_value_id local trigger_id local ns_a_state_before - local ns_b_state_before local ns_a_state_after - local ns_b_state_after create_global_action custom_action_id "$custom_action_name" --label "test_case=all-scopes" --label "fixture=${TEST_PREFIX}-custom-action" create_global_scs scs_id "$all_scopes_scs" --label "test_case=all-scopes" --label "fixture=${TEST_PREFIX}-scs" @@ -1846,3 +2061,615 @@ teardown_file() { assert_registered_resource_already_migrated_in_namespace "$rr_id" "$NS_A_ID" "$rr_target_id" assert_obligation_trigger_already_migrated_in_namespace "$trigger_id" "$NS_A_ID" "$trigger_target_id" } + +# Paths intentionally covered here for prune: +# - prune command validation rejects empty, invalid, and multi-scope CSV input +# and leaves otherwise-prunable fixtures untouched +# - action prune deletes labeled migrated legacy actions and retains actions +# that are still in use or were not migrated +# - SCS prune deletes labeled migrated legacy SCS and retains in-use, +# not-migrated, and unlabeled-target cases +# - subject-mapping prune deletes labeled migrated legacy mappings and retains +# not-migrated and unlabeled-target cases +# - registered-resource prune deletes labeled migrated legacy resources and +# values and retains not-migrated, unlabeled-target, and multi-namespace +# source cases +# - obligation-trigger prune deletes labeled migrated legacy triggers and +# retains not-migrated and unlabeled-target cases +# - every covered prune scope verifies idempotent reruns and uses namespace +# delta checks to confirm no unexpected target churn +# +# Paths that are not in these e2e prune tests: +# - planner-only or dry-run prune output, summary formatting, and explicit +# status bucket assertions such as delete/blocked/unresolved +# - interactive prune review and backup-confirmation flows + +# Covers prune scope validation paths: +# - reject an explicitly empty scope value +# - leave a prunable legacy source and migrated target untouched +@test "prune namespaced-policy rejects an empty scope" { + local action_name="${TEST_PREFIX}-prune-empty-scope" + local action_id + local action_target_id + local ns_a_state_before + local ns_a_state_after + + create_global_action action_id "$action_name" --label "test_case=prune-empty-scope" --label "fixture=${TEST_PREFIX}-source" + create_namespaced_action action_target_id "$NS_A_ID" "$action_name" --label "test_case=prune-empty-scope" --label "fixture=${TEST_PREFIX}-target" --label "migrated_from=$action_id" --label "migration_run=${TEST_PREFIX}-manual" + + ns_a_state_before=$(namespace_state_json "$NS_A_ID") + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json migrate prune namespaced-policy --commit --scope "" + assert_failure + assert_output --partial "Flag '--scope' is required" + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + assert_legacy_custom_action_still_exists "$action_id" "$action_name" + assert_action_already_migrated_in_namespace "$action_name" "$NS_A_ID" "$action_target_id" +} + +# Covers prune scope validation paths: +# - reject an invalid scope value +# - leave a prunable legacy source and migrated target untouched +@test "prune namespaced-policy rejects an invalid scope" { + local action_name="${TEST_PREFIX}-prune-invalid-scope" + local action_id + local action_target_id + local ns_a_state_before + local ns_a_state_after + + create_global_action action_id "$action_name" --label "test_case=prune-invalid-scope" --label "fixture=${TEST_PREFIX}-source" + create_namespaced_action action_target_id "$NS_A_ID" "$action_name" --label "test_case=prune-invalid-scope" --label "fixture=${TEST_PREFIX}-target" --label "migrated_from=$action_id" --label "migration_run=${TEST_PREFIX}-manual" + + ns_a_state_before=$(namespace_state_json "$NS_A_ID") + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json migrate prune namespaced-policy --commit --scope "not-a-real-scope" + assert_failure + assert_output --partial "invalid migration scope: not-a-real-scope" + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + assert_legacy_custom_action_still_exists "$action_id" "$action_name" + assert_action_already_migrated_in_namespace "$action_name" "$NS_A_ID" "$action_target_id" +} + +# Covers prune scope validation paths: +# - reject CSV scope values containing more than one scope +# - leave a prunable legacy source and migrated target untouched +@test "prune namespaced-policy rejects multiple CSV scopes" { + local action_name="${TEST_PREFIX}-prune-multiple-scopes" + local action_id + local action_target_id + local ns_a_state_before + local ns_a_state_after + + create_global_action action_id "$action_name" --label "test_case=prune-multiple-scopes" --label "fixture=${TEST_PREFIX}-source" + create_namespaced_action action_target_id "$NS_A_ID" "$action_name" --label "test_case=prune-multiple-scopes" --label "fixture=${TEST_PREFIX}-target" --label "migrated_from=$action_id" --label "migration_run=${TEST_PREFIX}-manual" + + ns_a_state_before=$(namespace_state_json "$NS_A_ID") + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json migrate prune namespaced-policy --commit --scope "actions,registered-resources" + assert_failure + assert_output --partial "prune planner accepts exactly one scope" + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + assert_legacy_custom_action_still_exists "$action_id" "$action_name" + assert_action_already_migrated_in_namespace "$action_name" "$NS_A_ID" "$action_target_id" +} + + +# Prune mixed-state coverage: each scope is pruned once while the plan contains +# deletable candidates and candidates that must remain. +# Covers action prune paths: +# - delete legacy custom actions with labeled migrated targets +# - retain legacy actions still referenced by subject mappings, registered resources, or obligation triggers +# - retain actions that were not migrated +# - check idempotency +@test "prune namespaced-policy actions handles delete, in-use, and not-migrated states together" { + local delete_a_name="${TEST_PREFIX}-prune-action-delete-a" + local delete_b_name="${TEST_PREFIX}-prune-action-delete-b" + local used_by_mapping_name="${TEST_PREFIX}-prune-action-used-by-mapping" + local used_by_rr_name="${TEST_PREFIX}-prune-action-used-by-rr" + local used_by_trigger_name="${TEST_PREFIX}-prune-action-used-by-trigger" + local not_migrated_name="${TEST_PREFIX}-prune-action-not-migrated" + local shared_scs='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-action"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local delete_a_id + local delete_b_id + local used_by_mapping_id + local used_by_rr_id + local used_by_trigger_id + local not_migrated_id + local delete_a_target_id + local delete_b_target_id + local used_by_mapping_target_id + local used_by_rr_target_id + local used_by_trigger_target_id + local shared_scs_id + local mapping_id + local rr_id + local rr_value_id + local obligation_id + local obligation_value_id + local trigger_id + local ns_a_state_before + local ns_a_state_after + + create_global_action delete_a_id "$delete_a_name" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-delete-a-source" + create_global_action delete_b_id "$delete_b_name" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-delete-b-source" + create_global_action used_by_mapping_id "$used_by_mapping_name" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-used-by-mapping-source" + create_global_action used_by_rr_id "$used_by_rr_name" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-used-by-rr-source" + create_global_action used_by_trigger_id "$used_by_trigger_name" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-used-by-trigger-source" + create_global_action not_migrated_id "$not_migrated_name" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-not-migrated-source" + + create_namespaced_action delete_a_target_id "$NS_A_ID" "$delete_a_name" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-delete-a-target" --label "migrated_from=$delete_a_id" --label "migration_run=${TEST_PREFIX}-manual" + create_namespaced_action delete_b_target_id "$NS_A_ID" "$delete_b_name" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-delete-b-target" --label "migrated_from=$delete_b_id" --label "migration_run=${TEST_PREFIX}-manual" + + create_global_scs shared_scs_id "$shared_scs" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-shared-scs" + create_legacy_subject_mapping mapping_id "$ATTR_A_VAL_1_ID" "$used_by_mapping_id" "$shared_scs_id" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-mapping-reference" + create_global_registered_resource rr_id "${TEST_PREFIX}-prune-action-rr" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-rr-reference" + create_registered_resource_value rr_value_id "$rr_id" "${TEST_PREFIX}-prune-action-rr-value" --action-attribute-value "$used_by_rr_id;$ATTR_A_VAL_1_ID" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-rr-value-reference" + create_namespaced_obligation obligation_id "$NS_A_ID" "${TEST_PREFIX}-prune-action-obligation" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-obligation-reference" + create_obligation_value obligation_value_id "$obligation_id" "${TEST_PREFIX}-prune-action-obligation-value" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-obligation-value-reference" + create_legacy_obligation_trigger trigger_id "$ATTR_A_VAL_1_ID" "$used_by_trigger_id" "$obligation_value_id" --client-id "${TEST_PREFIX}-prune-action-client" --label "test_case=prune-actions" --label "fixture=${TEST_PREFIX}-trigger-reference" + + ns_a_state_before=$(namespace_state_json "$NS_A_ID") + + run_namespaced_policy_prune_commit "actions" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + untrack_action_id "$delete_a_id" + untrack_action_id "$delete_b_id" + assert_legacy_custom_action_pruned "$delete_a_id" + assert_legacy_custom_action_pruned "$delete_b_id" + assert_action_already_migrated_in_namespace "$delete_a_name" "$NS_A_ID" "$delete_a_target_id" + assert_action_already_migrated_in_namespace "$delete_b_name" "$NS_A_ID" "$delete_b_target_id" + assert_legacy_custom_action_still_exists "$used_by_mapping_id" "$used_by_mapping_name" + assert_legacy_custom_action_still_exists "$used_by_rr_id" "$used_by_rr_name" + assert_legacy_custom_action_still_exists "$used_by_trigger_id" "$used_by_trigger_name" + assert_legacy_custom_action_still_exists "$not_migrated_id" "$not_migrated_name" + + ns_a_state_before="$ns_a_state_after" + + run_namespaced_policy_prune_commit "actions" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + assert_legacy_custom_action_pruned "$delete_a_id" + assert_legacy_custom_action_pruned "$delete_b_id" + assert_action_already_migrated_in_namespace "$delete_a_name" "$NS_A_ID" "$delete_a_target_id" + assert_action_already_migrated_in_namespace "$delete_b_name" "$NS_A_ID" "$delete_b_target_id" + assert_legacy_custom_action_still_exists "$used_by_mapping_id" "$used_by_mapping_name" + assert_legacy_custom_action_still_exists "$used_by_rr_id" "$used_by_rr_name" + assert_legacy_custom_action_still_exists "$used_by_trigger_id" "$used_by_trigger_name" + assert_legacy_custom_action_still_exists "$not_migrated_id" "$not_migrated_name" +} + +# Covers SCS prune paths: +# - delete legacy SCS with labeled migrated targets +# - retain SCS still referenced by a subject mapping +# - retain SCS that were not migrated +# - retain SCS with an unlabeled target +# - check idempotency +@test "prune namespaced-policy subject-condition-sets handles delete, in-use, not-migrated, and unlabeled-target states together" { + local delete_a_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-scs-delete-a"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local delete_b_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-scs-delete-b"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local used_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-scs-used"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local not_migrated_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-scs-not-migrated"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local unlabeled_target_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-scs-unlabeled-target"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local delete_a_id + local delete_b_id + local used_id + local not_migrated_id + local unlabeled_id + local delete_a_target_id + local delete_b_target_id + local used_target_id + local unlabeled_target_id + local action_id + local mapping_id + local ns_a_state_before + local ns_a_state_after + + create_global_scs delete_a_id "$delete_a_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-delete-a-source" + create_global_scs delete_b_id "$delete_b_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-delete-b-source" + create_global_scs used_id "$used_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-used-source" + create_global_scs not_migrated_id "$not_migrated_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-not-migrated-source" + create_global_scs unlabeled_id "$unlabeled_target_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-unlabeled-target-source" + + create_namespaced_scs delete_a_target_id "$NS_A_ID" "$delete_a_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-delete-a-target" --label "migrated_from=$delete_a_id" --label "migration_run=${TEST_PREFIX}-manual" + create_namespaced_scs delete_b_target_id "$NS_A_ID" "$delete_b_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-delete-b-target" --label "migrated_from=$delete_b_id" --label "migration_run=${TEST_PREFIX}-manual" + create_namespaced_scs used_target_id "$NS_A_ID" "$used_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-used-target" --label "migrated_from=$used_id" --label "migration_run=${TEST_PREFIX}-manual" + create_namespaced_scs unlabeled_target_id "$NS_A_ID" "$unlabeled_target_sets" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-unlabeled-target" + + create_global_action action_id "${TEST_PREFIX}-prune-scs-action" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-action-reference" + create_legacy_subject_mapping mapping_id "$ATTR_A_VAL_1_ID" "$action_id" "$used_id" --label "test_case=prune-scs" --label "fixture=${TEST_PREFIX}-mapping-reference" + + ns_a_state_before=$(namespace_state_json "$NS_A_ID") + + run_namespaced_policy_prune_commit "subject-condition-sets" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + untrack_scs_id "$delete_a_id" + untrack_scs_id "$delete_b_id" + assert_legacy_scs_pruned "$delete_a_id" + assert_legacy_scs_pruned "$delete_b_id" + assert_scs_target_still_exists "$delete_a_target_id" "$NS_A_ID" "$delete_a_id" + assert_scs_target_still_exists "$delete_b_target_id" "$NS_A_ID" "$delete_b_id" + assert_legacy_scs_still_exists "$used_id" + assert_legacy_scs_still_exists "$not_migrated_id" + assert_legacy_scs_still_exists "$unlabeled_id" + assert_scs_target_still_exists "$used_target_id" "$NS_A_ID" "$used_id" + + local unlabeled_target_json + run_otdfctl_scs get --id "$unlabeled_target_id" --json + unlabeled_target_json="$output" + assert_equal "$(echo "$unlabeled_target_json" | jq -r '.id // empty')" "$unlabeled_target_id" + assert_equal "$(echo "$unlabeled_target_json" | jq -r '.namespace.id')" "$NS_A_ID" + assert_equal "$(echo "$unlabeled_target_json" | jq -r '.metadata.labels.migrated_from // empty')" "" + + ns_a_state_before="$ns_a_state_after" + + run_namespaced_policy_prune_commit "subject-condition-sets" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + assert_legacy_scs_pruned "$delete_a_id" + assert_legacy_scs_pruned "$delete_b_id" + assert_scs_target_still_exists "$delete_a_target_id" "$NS_A_ID" "$delete_a_id" + assert_scs_target_still_exists "$delete_b_target_id" "$NS_A_ID" "$delete_b_id" + assert_legacy_scs_still_exists "$used_id" + assert_legacy_scs_still_exists "$not_migrated_id" + assert_legacy_scs_still_exists "$unlabeled_id" + assert_scs_target_still_exists "$used_target_id" "$NS_A_ID" "$used_id" + run_otdfctl_scs get --id "$unlabeled_target_id" --json + unlabeled_target_json="$output" + assert_equal "$(echo "$unlabeled_target_json" | jq -r '.id // empty')" "$unlabeled_target_id" + assert_equal "$(echo "$unlabeled_target_json" | jq -r '.namespace.id')" "$NS_A_ID" + assert_equal "$(echo "$unlabeled_target_json" | jq -r '.metadata.labels.migrated_from // empty')" "" +} + +# Covers subject-mapping prune paths: +# - delete migrated legacy mappings +# - retain mappings that were not migrated +# - retain mappings whose matching target is not labeled as migrated_from the source +# - check idempotency +@test "prune namespaced-policy subject-mappings handles delete, not-migrated, and unlabeled-target states together" { + local delete_a_action_name="${TEST_PREFIX}-prune-sm-delete-a" + local delete_b_action_name="${TEST_PREFIX}-prune-sm-delete-b" + local not_migrated_global_action_name="${TEST_PREFIX}-prune-sm-not-migrated" + local unlabeled_global_action_name="${TEST_PREFIX}-prune-sm-unlabeled-target" + local delete_a_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-sm-delete-a"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local delete_b_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-sm-delete-b"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local not_migrated_global_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-sm-not-migrated"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local unlabeled_global_sets='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-prune-sm-unlabeled-target"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local delete_a_action_id + local delete_b_action_id + local not_migrated_global_action_id + local unlabeled_global_action_id + local unlabeled_action_target_id + local delete_a_scs_id + local delete_b_scs_id + local not_migrated_global_scs_id + local unlabeled_global_scs_id + local unlabeled_scs_target_id + local delete_a_mapping_id + local delete_b_mapping_id + local not_migrated_global_mapping_id + local unlabeled_global_mapping_id + local delete_a_mapping_target_id + local delete_b_mapping_target_id + local unlabeled_mapping_target_id + local ns_a_state_before + local ns_a_state_after + + create_global_action delete_a_action_id "$delete_a_action_name" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-delete-a-action" + create_global_action delete_b_action_id "$delete_b_action_name" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-delete-b-action" + create_global_scs delete_a_scs_id "$delete_a_sets" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-delete-a-scs" + create_global_scs delete_b_scs_id "$delete_b_sets" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-delete-b-scs" + create_legacy_subject_mapping delete_a_mapping_id "$ATTR_A_VAL_1_ID" "$delete_a_action_id" "$delete_a_scs_id" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-delete-a-mapping" + create_legacy_subject_mapping delete_b_mapping_id "$ATTR_A_VAL_2_ID" "$delete_b_action_id" "$delete_b_scs_id" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-delete-b-mapping" + + run_namespaced_policy_commit "subject-mappings" + assert_success + + delete_a_mapping_target_id=$(subject_mapping_id_by_migrated_from "$NS_A_ID" "$delete_a_mapping_id") + delete_b_mapping_target_id=$(subject_mapping_id_by_migrated_from "$NS_A_ID" "$delete_b_mapping_id") + + create_global_action not_migrated_global_action_id "$not_migrated_global_action_name" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-not-migrated-action" + create_global_scs not_migrated_global_scs_id "$not_migrated_global_sets" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-not-migrated-scs" + create_legacy_subject_mapping not_migrated_global_mapping_id "$ATTR_A_VAL_1_ID" "$not_migrated_global_action_id" "$not_migrated_global_scs_id" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-not-migrated-mapping" + + create_global_action unlabeled_global_action_id "$unlabeled_global_action_name" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-unlabeled-target-action" + create_global_scs unlabeled_global_scs_id "$unlabeled_global_sets" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-unlabeled-target-scs" + create_legacy_subject_mapping unlabeled_global_mapping_id "$ATTR_A_VAL_2_ID" "$unlabeled_global_action_id" "$unlabeled_global_scs_id" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-unlabeled-target-mapping" + create_namespaced_action unlabeled_action_target_id "$NS_A_ID" "$unlabeled_global_action_name" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-unlabeled-target-action-target" --label "migrated_from=$unlabeled_global_action_id" --label "migration_run=${TEST_PREFIX}-manual" + create_namespaced_scs unlabeled_scs_target_id "$NS_A_ID" "$unlabeled_global_sets" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-unlabeled-target-scs-target" --label "migrated_from=$unlabeled_global_scs_id" --label "migration_run=${TEST_PREFIX}-manual" + create_namespaced_subject_mapping unlabeled_mapping_target_id "$NS_A_ID" "$ATTR_A_VAL_2_ID" "$unlabeled_action_target_id" "$unlabeled_scs_target_id" --label "test_case=prune-subject-mappings" --label "fixture=${TEST_PREFIX}-unlabeled-target-mapping-target" + + ns_a_state_before=$(namespace_state_json "$NS_A_ID") + + run_namespaced_policy_prune_commit "subject-mappings" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + untrack_subject_mapping_id "$delete_a_mapping_id" + untrack_subject_mapping_id "$delete_b_mapping_id" + assert_legacy_subject_mapping_pruned "$delete_a_mapping_id" + assert_legacy_subject_mapping_pruned "$delete_b_mapping_id" + assert_subject_mapping_target_still_exists "$delete_a_mapping_target_id" "$NS_A_ID" "$delete_a_mapping_id" + assert_subject_mapping_target_still_exists "$delete_b_mapping_target_id" "$NS_A_ID" "$delete_b_mapping_id" + assert_legacy_subject_mapping_still_exists "$ATTR_A_VAL_1_ID" "$not_migrated_global_mapping_id" + assert_legacy_subject_mapping_still_exists "$ATTR_A_VAL_2_ID" "$unlabeled_global_mapping_id" + assert_legacy_custom_action_still_exists "$delete_a_action_id" "$delete_a_action_name" + assert_legacy_scs_still_exists "$delete_a_scs_id" + assert_legacy_custom_action_still_exists "$delete_b_action_id" "$delete_b_action_name" + assert_legacy_scs_still_exists "$delete_b_scs_id" + assert_legacy_custom_action_still_exists "$not_migrated_global_action_id" "$not_migrated_global_action_name" + assert_legacy_scs_still_exists "$not_migrated_global_scs_id" + assert_legacy_custom_action_still_exists "$unlabeled_global_action_id" "$unlabeled_global_action_name" + assert_legacy_scs_still_exists "$unlabeled_global_scs_id" + + local unlabeled_mapping_target_json + run_otdfctl_sm get --id "$unlabeled_mapping_target_id" --json + unlabeled_mapping_target_json="$output" + assert_equal "$(echo "$unlabeled_mapping_target_json" | jq -r '.id // empty')" "$unlabeled_mapping_target_id" + assert_equal "$(echo "$unlabeled_mapping_target_json" | jq -r '.namespace.id')" "$NS_A_ID" + assert_equal "$(echo "$unlabeled_mapping_target_json" | jq -r '.metadata.labels.migrated_from // empty')" "" + + ns_a_state_before="$ns_a_state_after" + + run_namespaced_policy_prune_commit "subject-mappings" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + assert_legacy_subject_mapping_pruned "$delete_a_mapping_id" + assert_legacy_subject_mapping_pruned "$delete_b_mapping_id" + assert_subject_mapping_target_still_exists "$delete_a_mapping_target_id" "$NS_A_ID" "$delete_a_mapping_id" + assert_subject_mapping_target_still_exists "$delete_b_mapping_target_id" "$NS_A_ID" "$delete_b_mapping_id" + assert_legacy_subject_mapping_still_exists "$ATTR_A_VAL_1_ID" "$not_migrated_global_mapping_id" + assert_legacy_subject_mapping_still_exists "$ATTR_A_VAL_2_ID" "$unlabeled_global_mapping_id" + assert_legacy_custom_action_still_exists "$delete_a_action_id" "$delete_a_action_name" + assert_legacy_scs_still_exists "$delete_a_scs_id" + assert_legacy_custom_action_still_exists "$delete_b_action_id" "$delete_b_action_name" + assert_legacy_scs_still_exists "$delete_b_scs_id" + assert_legacy_custom_action_still_exists "$not_migrated_global_action_id" "$not_migrated_global_action_name" + assert_legacy_scs_still_exists "$not_migrated_global_scs_id" + assert_legacy_custom_action_still_exists "$unlabeled_global_action_id" "$unlabeled_global_action_name" + assert_legacy_scs_still_exists "$unlabeled_global_scs_id" + + run_otdfctl_sm get --id "$unlabeled_mapping_target_id" --json + unlabeled_mapping_target_json="$output" + assert_equal "$(echo "$unlabeled_mapping_target_json" | jq -r '.id // empty')" "$unlabeled_mapping_target_id" + assert_equal "$(echo "$unlabeled_mapping_target_json" | jq -r '.namespace.id')" "$NS_A_ID" + assert_equal "$(echo "$unlabeled_mapping_target_json" | jq -r '.metadata.labels.migrated_from // empty')" "" +} + +# Covers registered-resource prune paths: +# - delete migrated legacy resources and values +# - retain RRs that were not migrated +# - retain resources whose matching target is not labeled as migrated_from the source +# - retain a source resource with values from multiple namespaces +# - check idempotency +@test "prune namespaced-policy registered-resources handles delete, not-migrated, unlabeled-target, and multi-namespace-source states together" { + local delete_a_action_name="${TEST_PREFIX}-prune-rr-delete-a" + local delete_b_action_name="${TEST_PREFIX}-prune-rr-delete-b" + local not_migrated_global_action_name="${TEST_PREFIX}-prune-rr-not-migrated" + local unlabeled_global_action_name="${TEST_PREFIX}-prune-rr-unlabeled-target" + local delete_a_action_id + local delete_b_action_id + local not_migrated_global_action_id + local unlabeled_global_action_id + local unlabeled_action_target_id + local ns_a_read_action_id + local delete_a_rr_id + local delete_b_rr_id + local not_migrated_global_rr_id + local unlabeled_global_rr_id + local multi_namespace_rr_id + local delete_a_value_id + local delete_b_value_id + local not_migrated_global_value_id + local unlabeled_global_value_id + local multi_namespace_value_a_id + local multi_namespace_value_b_id + local delete_a_rr_target_id + local delete_b_rr_target_id + local delete_a_value_target_id + local delete_b_value_target_id + local unlabeled_rr_target_id + local unlabeled_value_target_id + local multi_namespace_rr_target_id + local multi_namespace_value_target_id + local ns_a_state_before + local ns_a_state_after + + create_global_action delete_a_action_id "$delete_a_action_name" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-delete-a-action" + create_global_action delete_b_action_id "$delete_b_action_name" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-delete-b-action" + create_global_registered_resource delete_a_rr_id "${TEST_PREFIX}-prune-rr-delete-a" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-delete-a-rr" + create_global_registered_resource delete_b_rr_id "${TEST_PREFIX}-prune-rr-delete-b" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-delete-b-rr" + create_registered_resource_value delete_a_value_id "$delete_a_rr_id" "${TEST_PREFIX}-delete-a-value" --action-attribute-value "$delete_a_action_id;$ATTR_A_VAL_1_ID" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-delete-a-value" + create_registered_resource_value delete_b_value_id "$delete_b_rr_id" "${TEST_PREFIX}-delete-b-value" --action-attribute-value "$delete_b_action_id;$ATTR_A_VAL_2_ID" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-delete-b-value" + + run_namespaced_policy_commit "registered-resources" + assert_success + + delete_a_rr_target_id=$(registered_resource_id_by_migrated_from "$NS_A_ID" "$delete_a_rr_id") + delete_b_rr_target_id=$(registered_resource_id_by_migrated_from "$NS_A_ID" "$delete_b_rr_id") + delete_a_value_target_id=$(registered_resource_value_id_by_migrated_from "$delete_a_rr_target_id" "$delete_a_value_id") + delete_b_value_target_id=$(registered_resource_value_id_by_migrated_from "$delete_b_rr_target_id" "$delete_b_value_id") + + create_global_action not_migrated_global_action_id "$not_migrated_global_action_name" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-not-migrated-action" + create_global_registered_resource not_migrated_global_rr_id "${TEST_PREFIX}-prune-rr-not-migrated" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-not-migrated-rr" + create_registered_resource_value not_migrated_global_value_id "$not_migrated_global_rr_id" "${TEST_PREFIX}-not-migrated-value" --action-attribute-value "$not_migrated_global_action_id;$ATTR_A_VAL_1_ID" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-not-migrated-value" + + create_global_action unlabeled_global_action_id "$unlabeled_global_action_name" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-unlabeled-target-action" + create_global_registered_resource unlabeled_global_rr_id "${TEST_PREFIX}-prune-rr-unlabeled-target" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-unlabeled-target-rr" + create_registered_resource_value unlabeled_global_value_id "$unlabeled_global_rr_id" "${TEST_PREFIX}-unlabeled-target-value" --action-attribute-value "$unlabeled_global_action_id;$ATTR_A_VAL_2_ID" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-unlabeled-target-value" + create_namespaced_action unlabeled_action_target_id "$NS_A_ID" "$unlabeled_global_action_name" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-unlabeled-target-action-target" --label "migrated_from=$unlabeled_global_action_id" --label "migration_run=${TEST_PREFIX}-manual" + create_namespaced_registered_resource unlabeled_rr_target_id "$NS_A_ID" "${TEST_PREFIX}-prune-rr-unlabeled-target" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-unlabeled-target-rr-target" + create_registered_resource_value unlabeled_value_target_id "$unlabeled_rr_target_id" "${TEST_PREFIX}-unlabeled-target-value" --action-attribute-value "$unlabeled_action_target_id;$ATTR_A_VAL_2_ID" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-unlabeled-target-value-target" + + lookup_namespaced_action_id ns_a_read_action_id "read" "$NS_A_ID" + create_global_registered_resource multi_namespace_rr_id "${TEST_PREFIX}-prune-rr-multi-namespace" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-multi-namespace-rr" + create_registered_resource_value multi_namespace_value_a_id "$multi_namespace_rr_id" "${TEST_PREFIX}-multi-namespace-a" --action-attribute-value "$GLOBAL_READ_ID;$ATTR_A_VAL_1_ID" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-multi-namespace-value-a" + create_registered_resource_value multi_namespace_value_b_id "$multi_namespace_rr_id" "${TEST_PREFIX}-multi-namespace-b" --action-attribute-value "$GLOBAL_READ_ID;$ATTR_B_VAL_1_ID" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-multi-namespace-value-b" + create_namespaced_registered_resource multi_namespace_rr_target_id "$NS_A_ID" "${TEST_PREFIX}-prune-rr-multi-namespace" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-multi-namespace-rr-target" --label "migrated_from=$multi_namespace_rr_id" --label "migration_run=${TEST_PREFIX}-manual" + create_registered_resource_value multi_namespace_value_target_id "$multi_namespace_rr_target_id" "${TEST_PREFIX}-multi-namespace-a" --action-attribute-value "$ns_a_read_action_id;$ATTR_A_VAL_1_ID" --label "test_case=prune-registered-resources" --label "fixture=${TEST_PREFIX}-multi-namespace-value-target" --label "migrated_from=$multi_namespace_value_a_id" --label "migration_run=${TEST_PREFIX}-manual" + + ns_a_state_before=$(namespace_state_json "$NS_A_ID") + + run_namespaced_policy_prune_commit "registered-resources" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + untrack_registered_resource_id "$delete_a_rr_id" + untrack_registered_resource_id "$delete_b_rr_id" + untrack_registered_resource_value_id "$delete_a_value_id" + untrack_registered_resource_value_id "$delete_b_value_id" + assert_legacy_registered_resource_pruned "$delete_a_rr_id" "$delete_a_value_id" + assert_legacy_registered_resource_pruned "$delete_b_rr_id" "$delete_b_value_id" + assert_registered_resource_target_still_exists "$delete_a_rr_target_id" "$delete_a_value_target_id" "$NS_A_ID" "$delete_a_rr_id" "$delete_a_value_id" + assert_registered_resource_target_still_exists "$delete_b_rr_target_id" "$delete_b_value_target_id" "$NS_A_ID" "$delete_b_rr_id" "$delete_b_value_id" + assert_legacy_registered_resource_still_exists "$not_migrated_global_rr_id" "$not_migrated_global_value_id" "${TEST_PREFIX}-prune-rr-not-migrated" "${TEST_PREFIX}-not-migrated-value" + assert_legacy_registered_resource_still_exists "$unlabeled_global_rr_id" "$unlabeled_global_value_id" "${TEST_PREFIX}-prune-rr-unlabeled-target" "${TEST_PREFIX}-unlabeled-target-value" + assert_legacy_registered_resource_still_exists "$multi_namespace_rr_id" "$multi_namespace_value_a_id" "${TEST_PREFIX}-prune-rr-multi-namespace" "${TEST_PREFIX}-multi-namespace-a" + assert_legacy_registered_resource_still_exists "$multi_namespace_rr_id" "$multi_namespace_value_b_id" "${TEST_PREFIX}-prune-rr-multi-namespace" "${TEST_PREFIX}-multi-namespace-b" + assert_registered_resource_target_still_exists "$multi_namespace_rr_target_id" "$multi_namespace_value_target_id" "$NS_A_ID" "$multi_namespace_rr_id" "$multi_namespace_value_a_id" + assert_registered_resource_unlabeled_target_still_exists "$unlabeled_rr_target_id" "$unlabeled_value_target_id" "$NS_A_ID" "${TEST_PREFIX}-prune-rr-unlabeled-target" "${TEST_PREFIX}-unlabeled-target-value" "$unlabeled_action_target_id" "$ATTR_A_VAL_2_ID" + + ns_a_state_before="$ns_a_state_after" + + run_namespaced_policy_prune_commit "registered-resources" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + assert_legacy_registered_resource_pruned "$delete_a_rr_id" "$delete_a_value_id" + assert_legacy_registered_resource_pruned "$delete_b_rr_id" "$delete_b_value_id" + assert_registered_resource_target_still_exists "$delete_a_rr_target_id" "$delete_a_value_target_id" "$NS_A_ID" "$delete_a_rr_id" "$delete_a_value_id" + assert_registered_resource_target_still_exists "$delete_b_rr_target_id" "$delete_b_value_target_id" "$NS_A_ID" "$delete_b_rr_id" "$delete_b_value_id" + assert_legacy_registered_resource_still_exists "$not_migrated_global_rr_id" "$not_migrated_global_value_id" "${TEST_PREFIX}-prune-rr-not-migrated" "${TEST_PREFIX}-not-migrated-value" + assert_legacy_registered_resource_still_exists "$unlabeled_global_rr_id" "$unlabeled_global_value_id" "${TEST_PREFIX}-prune-rr-unlabeled-target" "${TEST_PREFIX}-unlabeled-target-value" + assert_legacy_registered_resource_still_exists "$multi_namespace_rr_id" "$multi_namespace_value_a_id" "${TEST_PREFIX}-prune-rr-multi-namespace" "${TEST_PREFIX}-multi-namespace-a" + assert_legacy_registered_resource_still_exists "$multi_namespace_rr_id" "$multi_namespace_value_b_id" "${TEST_PREFIX}-prune-rr-multi-namespace" "${TEST_PREFIX}-multi-namespace-b" + assert_registered_resource_unlabeled_target_still_exists "$unlabeled_rr_target_id" "$unlabeled_value_target_id" "$NS_A_ID" "${TEST_PREFIX}-prune-rr-unlabeled-target" "${TEST_PREFIX}-unlabeled-target-value" "$unlabeled_action_target_id" "$ATTR_A_VAL_2_ID" + assert_registered_resource_target_still_exists "$multi_namespace_rr_target_id" "$multi_namespace_value_target_id" "$NS_A_ID" "$multi_namespace_rr_id" "$multi_namespace_value_a_id" +} + +# Covers obligation-trigger prune paths: +# - delete migrated legacy triggers +# - retain triggers that were not migrated +# - retain triggers whose matching target is not labeled as migrated_from the source +# - check idempotency +@test "prune namespaced-policy obligation-triggers handles delete, not-migrated, and unlabeled-target states together" { + local delete_a_action_name="${TEST_PREFIX}-prune-trigger-delete-a" + local delete_b_action_name="${TEST_PREFIX}-prune-trigger-delete-b" + local not_migrated_global_action_name="${TEST_PREFIX}-prune-trigger-not-migrated" + local unlabeled_global_action_name="${TEST_PREFIX}-prune-trigger-unlabeled-target" + local delete_a_action_id + local delete_b_action_id + local not_migrated_global_action_id + local unlabeled_global_action_id + local unlabeled_action_target_id + local delete_a_obligation_id + local delete_b_obligation_id + local not_migrated_source_obligation_id + local unlabeled_source_obligation_id + local delete_a_value_id + local delete_b_value_id + local not_migrated_source_value_id + local unlabeled_source_value_id + local delete_a_trigger_id + local delete_b_trigger_id + local not_migrated_source_trigger_id + local unlabeled_source_trigger_id + local delete_a_trigger_target_id + local delete_b_trigger_target_id + local unlabeled_trigger_target_id + local ns_a_state_before + local ns_a_state_after + + create_global_action delete_a_action_id "$delete_a_action_name" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-delete-a-action" + create_global_action delete_b_action_id "$delete_b_action_name" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-delete-b-action" + create_namespaced_obligation delete_a_obligation_id "$NS_A_ID" "${TEST_PREFIX}-prune-trigger-delete-a" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-delete-a-obligation" + create_namespaced_obligation delete_b_obligation_id "$NS_A_ID" "${TEST_PREFIX}-prune-trigger-delete-b" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-delete-b-obligation" + create_obligation_value delete_a_value_id "$delete_a_obligation_id" "${TEST_PREFIX}-delete-a-value" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-delete-a-value" + create_obligation_value delete_b_value_id "$delete_b_obligation_id" "${TEST_PREFIX}-delete-b-value" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-delete-b-value" + create_legacy_obligation_trigger delete_a_trigger_id "$ATTR_A_VAL_1_ID" "$delete_a_action_id" "$delete_a_value_id" --client-id "${TEST_PREFIX}-delete-a-client" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-delete-a-trigger" + create_legacy_obligation_trigger delete_b_trigger_id "$ATTR_A_VAL_2_ID" "$delete_b_action_id" "$delete_b_value_id" --client-id "${TEST_PREFIX}-delete-b-client" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-delete-b-trigger" + + run_namespaced_policy_commit "obligation-triggers" + assert_success + + delete_a_trigger_target_id=$(obligation_trigger_id_by_migrated_from "$NS_A_ID" "$delete_a_trigger_id") + delete_b_trigger_target_id=$(obligation_trigger_id_by_migrated_from "$NS_A_ID" "$delete_b_trigger_id") + + create_global_action not_migrated_global_action_id "$not_migrated_global_action_name" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-not-migrated-action" + create_namespaced_obligation not_migrated_source_obligation_id "$NS_A_ID" "${TEST_PREFIX}-prune-trigger-not-migrated" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-not-migrated-obligation" + create_obligation_value not_migrated_source_value_id "$not_migrated_source_obligation_id" "${TEST_PREFIX}-not-migrated-value" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-not-migrated-value" + create_legacy_obligation_trigger not_migrated_source_trigger_id "$ATTR_A_VAL_1_ID" "$not_migrated_global_action_id" "$not_migrated_source_value_id" --client-id "${TEST_PREFIX}-not-migrated-client" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-not-migrated-trigger" + + create_global_action unlabeled_global_action_id "$unlabeled_global_action_name" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-unlabeled-target-action" + create_namespaced_obligation unlabeled_source_obligation_id "$NS_A_ID" "${TEST_PREFIX}-prune-trigger-unlabeled-target" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-unlabeled-target-obligation" + create_obligation_value unlabeled_source_value_id "$unlabeled_source_obligation_id" "${TEST_PREFIX}-unlabeled-target-value" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-unlabeled-target-value" + create_legacy_obligation_trigger unlabeled_source_trigger_id "$ATTR_A_VAL_2_ID" "$unlabeled_global_action_id" "$unlabeled_source_value_id" --client-id "${TEST_PREFIX}-unlabeled-target-client" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-unlabeled-target-trigger" + create_namespaced_action unlabeled_action_target_id "$NS_A_ID" "$unlabeled_global_action_name" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-unlabeled-target-action-target" --label "migrated_from=$unlabeled_global_action_id" --label "migration_run=${TEST_PREFIX}-manual" + run_otdfctl_obligation_triggers create --attribute-value "$ATTR_A_VAL_2_ID" --action "$unlabeled_action_target_id" --obligation-value "$unlabeled_source_value_id" --client-id "${TEST_PREFIX}-unlabeled-target-client" --label "test_case=prune-obligation-triggers" --label "fixture=${TEST_PREFIX}-unlabeled-target-trigger-target" --json + unlabeled_trigger_target_id=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$unlabeled_trigger_target_id" "" + track_obligation_trigger_id "$unlabeled_trigger_target_id" + + ns_a_state_before=$(namespace_state_json "$NS_A_ID") + + run_namespaced_policy_prune_commit "obligation-triggers" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 -2 # Obligation triggers are already namespaced, they just need to be deleted/recreated; that's why there should be 2 less after prune. + + untrack_obligation_trigger_id "$delete_a_trigger_id" + untrack_obligation_trigger_id "$delete_b_trigger_id" + assert_legacy_obligation_trigger_pruned "$delete_a_trigger_id" "$NS_A_ID" + assert_legacy_obligation_trigger_pruned "$delete_b_trigger_id" "$NS_A_ID" + assert_obligation_trigger_target_still_exists "$delete_a_trigger_target_id" "$NS_A_ID" "$delete_a_trigger_id" + assert_obligation_trigger_target_still_exists "$delete_b_trigger_target_id" "$NS_A_ID" "$delete_b_trigger_id" + assert_legacy_obligation_trigger_still_exists "$not_migrated_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_1_ID" "$not_migrated_global_action_id" "$not_migrated_source_value_id" "${TEST_PREFIX}-not-migrated-client" + assert_legacy_obligation_trigger_still_exists "$unlabeled_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_2_ID" "$unlabeled_global_action_id" "$unlabeled_source_value_id" "${TEST_PREFIX}-unlabeled-target-client" + + ns_a_state_before="$ns_a_state_after" + + run_namespaced_policy_prune_commit "obligation-triggers" + assert_success + + ns_a_state_after=$(namespace_state_json "$NS_A_ID") + assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + + assert_legacy_obligation_trigger_pruned "$delete_a_trigger_id" "$NS_A_ID" + assert_legacy_obligation_trigger_pruned "$delete_b_trigger_id" "$NS_A_ID" + assert_obligation_trigger_target_still_exists "$delete_a_trigger_target_id" "$NS_A_ID" "$delete_a_trigger_id" + assert_obligation_trigger_target_still_exists "$delete_b_trigger_target_id" "$NS_A_ID" "$delete_b_trigger_id" + assert_legacy_obligation_trigger_still_exists "$not_migrated_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_1_ID" "$not_migrated_global_action_id" "$not_migrated_source_value_id" "${TEST_PREFIX}-not-migrated-client" + assert_legacy_obligation_trigger_still_exists "$unlabeled_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_2_ID" "$unlabeled_global_action_id" "$unlabeled_source_value_id" "${TEST_PREFIX}-unlabeled-target-client" +} From b52bfb3031bd30d801d3af830299504636e11bed Mon Sep 17 00:00:00 2001 From: Chris Reed Date: Mon, 18 May 2026 11:12:54 -0500 Subject: [PATCH 2/3] address comments. --- otdfctl/docs/man/migrate/prune/_index.md | 2 +- .../man/migrate/prune/namespaced-policy.md | 2 +- otdfctl/e2e/migrate-namespaced-policy.bats | 28 ++++++++++++------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/otdfctl/docs/man/migrate/prune/_index.md b/otdfctl/docs/man/migrate/prune/_index.md index f458e1baa1..493f8370c8 100644 --- a/otdfctl/docs/man/migrate/prune/_index.md +++ b/otdfctl/docs/man/migrate/prune/_index.md @@ -9,6 +9,6 @@ command: Available subcommands currently include `namespaced-policy` for policy cleanup workflows. -The parent `migrate` command provides the shared `--commit` flag used to apply deletions. +The parent `migrate` command provides the shared `--commit` and `--interactive` flags. `--interactive` lets you review prune plans before execution, and when paired with `--commit` it also adds confirmation before deletions are applied. `migrate prune` is not the same as `otdfctl policy subject-condition-sets prune`. The existing subject-condition-set prune command deletes unmapped subject condition sets. `migrate prune` is only for cleaning up legacy objects after a migration run. diff --git a/otdfctl/docs/man/migrate/prune/namespaced-policy.md b/otdfctl/docs/man/migrate/prune/namespaced-policy.md index 16ab541fb4..435bee5d87 100644 --- a/otdfctl/docs/man/migrate/prune/namespaced-policy.md +++ b/otdfctl/docs/man/migrate/prune/namespaced-policy.md @@ -20,7 +20,7 @@ The command prints a human-readable prune summary to stdout. Dry runs show the p `namespaced-policy` rebuilds the live dependency graph, inspects migration labels, and deletes only legacy objects it can prove are safe to remove for the selected scope. -The parent `migrate` command provides the shared `--commit` flag used to apply deletions. +The parent `migrate` command provides the shared `--commit` and `--interactive` flags. `--interactive` lets you review the prune plan before execution, and when paired with `--commit` it also asks for backup confirmation and per-delete confirmation before any deletion is applied. ## Pre-requisites diff --git a/otdfctl/e2e/migrate-namespaced-policy.bats b/otdfctl/e2e/migrate-namespaced-policy.bats index e9ac90c91b..8a6eb94c6c 100644 --- a/otdfctl/e2e/migrate-namespaced-policy.bats +++ b/otdfctl/e2e/migrate-namespaced-policy.bats @@ -173,31 +173,37 @@ remove_tracked_id() { untrack_action_id() { local action_id="$1" TRACKED_ACTION_IDS="$(remove_tracked_id "$action_id" "$TRACKED_ACTION_IDS")" + [ -z "$TRACKED_ACTION_IDS" ] || TRACKED_ACTION_IDS="${TRACKED_ACTION_IDS}"$'\n' } untrack_registered_resource_id() { local resource_id="$1" TRACKED_REGISTERED_RESOURCE_IDS="$(remove_tracked_id "$resource_id" "$TRACKED_REGISTERED_RESOURCE_IDS")" + [ -z "$TRACKED_REGISTERED_RESOURCE_IDS" ] || TRACKED_REGISTERED_RESOURCE_IDS="${TRACKED_REGISTERED_RESOURCE_IDS}"$'\n' } untrack_registered_resource_value_id() { local resource_value_id="$1" TRACKED_REGISTERED_RESOURCE_VALUE_IDS="$(remove_tracked_id "$resource_value_id" "$TRACKED_REGISTERED_RESOURCE_VALUE_IDS")" + [ -z "$TRACKED_REGISTERED_RESOURCE_VALUE_IDS" ] || TRACKED_REGISTERED_RESOURCE_VALUE_IDS="${TRACKED_REGISTERED_RESOURCE_VALUE_IDS}"$'\n' } untrack_scs_id() { local scs_id="$1" TRACKED_SCS_IDS="$(remove_tracked_id "$scs_id" "$TRACKED_SCS_IDS")" + [ -z "$TRACKED_SCS_IDS" ] || TRACKED_SCS_IDS="${TRACKED_SCS_IDS}"$'\n' } untrack_subject_mapping_id() { local subject_mapping_id="$1" TRACKED_SUBJECT_MAPPING_IDS="$(remove_tracked_id "$subject_mapping_id" "$TRACKED_SUBJECT_MAPPING_IDS")" + [ -z "$TRACKED_SUBJECT_MAPPING_IDS" ] || TRACKED_SUBJECT_MAPPING_IDS="${TRACKED_SUBJECT_MAPPING_IDS}"$'\n' } untrack_obligation_trigger_id() { local obligation_trigger_id="$1" TRACKED_OBLIGATION_TRIGGER_IDS="$(remove_tracked_id "$obligation_trigger_id" "$TRACKED_OBLIGATION_TRIGGER_IDS")" + [ -z "$TRACKED_OBLIGATION_TRIGGER_IDS" ] || TRACKED_OBLIGATION_TRIGGER_IDS="${TRACKED_OBLIGATION_TRIGGER_IDS}"$'\n' } create_action() { @@ -1990,7 +1996,9 @@ teardown_file() { local obligation_value_id local trigger_id local ns_a_state_before + local ns_b_state_before local ns_a_state_after + local ns_b_state_after create_global_action custom_action_id "$custom_action_name" --label "test_case=all-scopes" --label "fixture=${TEST_PREFIX}-custom-action" create_global_scs scs_id "$all_scopes_scs" --label "test_case=all-scopes" --label "fixture=${TEST_PREFIX}-scs" @@ -2225,10 +2233,10 @@ teardown_file() { ns_a_state_after=$(namespace_state_json "$NS_A_ID") assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 - untrack_action_id "$delete_a_id" - untrack_action_id "$delete_b_id" assert_legacy_custom_action_pruned "$delete_a_id" assert_legacy_custom_action_pruned "$delete_b_id" + untrack_action_id "$delete_a_id" + untrack_action_id "$delete_b_id" assert_action_already_migrated_in_namespace "$delete_a_name" "$NS_A_ID" "$delete_a_target_id" assert_action_already_migrated_in_namespace "$delete_b_name" "$NS_A_ID" "$delete_b_target_id" assert_legacy_custom_action_still_exists "$used_by_mapping_id" "$used_by_mapping_name" @@ -2302,10 +2310,10 @@ teardown_file() { ns_a_state_after=$(namespace_state_json "$NS_A_ID") assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 - untrack_scs_id "$delete_a_id" - untrack_scs_id "$delete_b_id" assert_legacy_scs_pruned "$delete_a_id" assert_legacy_scs_pruned "$delete_b_id" + untrack_scs_id "$delete_a_id" + untrack_scs_id "$delete_b_id" assert_scs_target_still_exists "$delete_a_target_id" "$NS_A_ID" "$delete_a_id" assert_scs_target_still_exists "$delete_b_target_id" "$NS_A_ID" "$delete_b_id" assert_legacy_scs_still_exists "$used_id" @@ -2409,10 +2417,10 @@ teardown_file() { ns_a_state_after=$(namespace_state_json "$NS_A_ID") assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 - untrack_subject_mapping_id "$delete_a_mapping_id" - untrack_subject_mapping_id "$delete_b_mapping_id" assert_legacy_subject_mapping_pruned "$delete_a_mapping_id" assert_legacy_subject_mapping_pruned "$delete_b_mapping_id" + untrack_subject_mapping_id "$delete_a_mapping_id" + untrack_subject_mapping_id "$delete_b_mapping_id" assert_subject_mapping_target_still_exists "$delete_a_mapping_target_id" "$NS_A_ID" "$delete_a_mapping_id" assert_subject_mapping_target_still_exists "$delete_b_mapping_target_id" "$NS_A_ID" "$delete_b_mapping_id" assert_legacy_subject_mapping_still_exists "$ATTR_A_VAL_1_ID" "$not_migrated_global_mapping_id" @@ -2543,12 +2551,12 @@ teardown_file() { ns_a_state_after=$(namespace_state_json "$NS_A_ID") assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 0 + assert_legacy_registered_resource_pruned "$delete_a_rr_id" "$delete_a_value_id" + assert_legacy_registered_resource_pruned "$delete_b_rr_id" "$delete_b_value_id" untrack_registered_resource_id "$delete_a_rr_id" untrack_registered_resource_id "$delete_b_rr_id" untrack_registered_resource_value_id "$delete_a_value_id" untrack_registered_resource_value_id "$delete_b_value_id" - assert_legacy_registered_resource_pruned "$delete_a_rr_id" "$delete_a_value_id" - assert_legacy_registered_resource_pruned "$delete_b_rr_id" "$delete_b_value_id" assert_registered_resource_target_still_exists "$delete_a_rr_target_id" "$delete_a_value_target_id" "$NS_A_ID" "$delete_a_rr_id" "$delete_a_value_id" assert_registered_resource_target_still_exists "$delete_b_rr_target_id" "$delete_b_value_target_id" "$NS_A_ID" "$delete_b_rr_id" "$delete_b_value_id" assert_legacy_registered_resource_still_exists "$not_migrated_global_rr_id" "$not_migrated_global_value_id" "${TEST_PREFIX}-prune-rr-not-migrated" "${TEST_PREFIX}-not-migrated-value" @@ -2649,10 +2657,10 @@ teardown_file() { ns_a_state_after=$(namespace_state_json "$NS_A_ID") assert_namespace_state_delta "$ns_a_state_before" "$ns_a_state_after" 0 0 0 0 -2 # Obligation triggers are already namespaced, they just need to be deleted/recreated; that's why there should be 2 less after prune. - untrack_obligation_trigger_id "$delete_a_trigger_id" - untrack_obligation_trigger_id "$delete_b_trigger_id" assert_legacy_obligation_trigger_pruned "$delete_a_trigger_id" "$NS_A_ID" assert_legacy_obligation_trigger_pruned "$delete_b_trigger_id" "$NS_A_ID" + untrack_obligation_trigger_id "$delete_a_trigger_id" + untrack_obligation_trigger_id "$delete_b_trigger_id" assert_obligation_trigger_target_still_exists "$delete_a_trigger_target_id" "$NS_A_ID" "$delete_a_trigger_id" assert_obligation_trigger_target_still_exists "$delete_b_trigger_target_id" "$NS_A_ID" "$delete_b_trigger_id" assert_legacy_obligation_trigger_still_exists "$not_migrated_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_1_ID" "$not_migrated_global_action_id" "$not_migrated_source_value_id" "${TEST_PREFIX}-not-migrated-client" From 473c15f35aa53e1323e1f516312ed5f0007bb1a3 Mon Sep 17 00:00:00 2001 From: Chris Reed Date: Mon, 18 May 2026 13:40:14 -0500 Subject: [PATCH 3/3] address comments. --- .../man/migrate/prune/namespaced-policy.md | 8 ++++++- otdfctl/e2e/migrate-namespaced-policy.bats | 24 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/otdfctl/docs/man/migrate/prune/namespaced-policy.md b/otdfctl/docs/man/migrate/prune/namespaced-policy.md index 435bee5d87..a61fcbcdf9 100644 --- a/otdfctl/docs/man/migrate/prune/namespaced-policy.md +++ b/otdfctl/docs/man/migrate/prune/namespaced-policy.md @@ -63,4 +63,10 @@ otdfctl migrate prune namespaced-policy --scope=obligation-triggers --interactiv ## Other Information -1. Actions and subject-condition-sets are pruned a little differently from the other scopes. Instead of reusing the resolved migration view, prune classifies them directly from the current legacy objects, their current legacy references, and the canonical migrated targets it can find. We do that because actions and subject-condition-sets are expected to be pruned last. By the time you reach those scopes, their legacy dependents such as subject mappings, registered resources, and obligation triggers should already be gone, so the safest decision comes from checking the live legacy dependency graph at prune time. That also means some actions or subject-condition-sets that were never used by any other legacy policy object can still end up `blocked`. If prune cannot find a canonical migrated target for the source object, it leaves the source in place as `blocked` instead of assuming it is safe to delete. For example, if a custom action `decrypt` is no longer referenced by any legacy subject mapping, registered resource, or obligation trigger and prune finds a namespaced `decrypt` target with `metadata.labels.migrated_from=`, the source action is safe to delete. If no canonical namespaced `decrypt` target exists, the source action is reported as `blocked` because prune cannot prove that the object was actually migrated. +1. Action / Subject-Condition-Set pruning + + 1a. Actions and subject-condition-sets are pruned a little differently from the other scopes. Instead of reusing the resolved migration view, prune classifies them directly from the current legacy objects, their current legacy references, and the canonical migrated targets it can find. + + 1b. We do that because actions and subject-condition-sets are expected to be pruned last. By the time you reach those scopes, their legacy dependents such as subject mappings, registered resources, and obligation triggers should already be gone, so the safest decision comes from checking the live legacy dependency graph at prune time. That also means some actions or subject-condition-sets that were never used by any other legacy policy object can still end up `blocked`. + + 1c. If prune cannot find a canonical migrated target for the source object, it leaves the source in place as `blocked` instead of assuming it is safe to delete. For example, if a custom action `decrypt` is no longer referenced by any legacy subject mapping, registered resource, or obligation trigger and prune finds a namespaced `decrypt` target with `metadata.labels.migrated_from=`, the source action is safe to delete. If no canonical namespaced `decrypt` target exists, the source action is reported as `blocked` because prune cannot prove that the object was actually migrated. diff --git a/otdfctl/e2e/migrate-namespaced-policy.bats b/otdfctl/e2e/migrate-namespaced-policy.bats index 8a6eb94c6c..858310f27a 100644 --- a/otdfctl/e2e/migrate-namespaced-policy.bats +++ b/otdfctl/e2e/migrate-namespaced-policy.bats @@ -1330,6 +1330,25 @@ assert_obligation_trigger_target_still_exists() { assert_equal "$(echo "$target_trigger_json" | jq -r '.metadata.labels.migrated_from')" "$source_trigger_id" } +assert_obligation_trigger_unlabeled_target_still_exists() { + local target_trigger_id="$1" + local namespace_id="$2" + local attribute_value_id="$3" + local action_id="$4" + local obligation_value_id="$5" + local client_id="$6" + + local target_trigger_json + target_trigger_json=$(obligation_trigger_json_by_id "$target_trigger_id" "$namespace_id") + + assert_equal "$(echo "$target_trigger_json" | jq -r '.id // empty')" "$target_trigger_id" + assert_equal "$(echo "$target_trigger_json" | jq -r '.attribute_value.id')" "$attribute_value_id" + assert_equal "$(echo "$target_trigger_json" | jq -r '.action.id')" "$action_id" + assert_equal "$(echo "$target_trigger_json" | jq -r '.obligation_value.id')" "$obligation_value_id" + assert_equal "$(echo "$target_trigger_json" | jq -r '.context[0].pep.client_id')" "$client_id" + assert_equal "$(echo "$target_trigger_json" | jq -r '.metadata.labels.migrated_from // empty')" "" +} + assert_scs_already_migrated_in_namespace() { local source_scs_id="$1" local namespace_id="$2" @@ -2194,9 +2213,6 @@ teardown_file() { local not_migrated_id local delete_a_target_id local delete_b_target_id - local used_by_mapping_target_id - local used_by_rr_target_id - local used_by_trigger_target_id local shared_scs_id local mapping_id local rr_id @@ -2665,6 +2681,7 @@ teardown_file() { assert_obligation_trigger_target_still_exists "$delete_b_trigger_target_id" "$NS_A_ID" "$delete_b_trigger_id" assert_legacy_obligation_trigger_still_exists "$not_migrated_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_1_ID" "$not_migrated_global_action_id" "$not_migrated_source_value_id" "${TEST_PREFIX}-not-migrated-client" assert_legacy_obligation_trigger_still_exists "$unlabeled_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_2_ID" "$unlabeled_global_action_id" "$unlabeled_source_value_id" "${TEST_PREFIX}-unlabeled-target-client" + assert_obligation_trigger_unlabeled_target_still_exists "$unlabeled_trigger_target_id" "$NS_A_ID" "$ATTR_A_VAL_2_ID" "$unlabeled_action_target_id" "$unlabeled_source_value_id" "${TEST_PREFIX}-unlabeled-target-client" ns_a_state_before="$ns_a_state_after" @@ -2680,4 +2697,5 @@ teardown_file() { assert_obligation_trigger_target_still_exists "$delete_b_trigger_target_id" "$NS_A_ID" "$delete_b_trigger_id" assert_legacy_obligation_trigger_still_exists "$not_migrated_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_1_ID" "$not_migrated_global_action_id" "$not_migrated_source_value_id" "${TEST_PREFIX}-not-migrated-client" assert_legacy_obligation_trigger_still_exists "$unlabeled_source_trigger_id" "$NS_A_ID" "$ATTR_A_VAL_2_ID" "$unlabeled_global_action_id" "$unlabeled_source_value_id" "${TEST_PREFIX}-unlabeled-target-client" + assert_obligation_trigger_unlabeled_target_still_exists "$unlabeled_trigger_target_id" "$NS_A_ID" "$ATTR_A_VAL_2_ID" "$unlabeled_action_target_id" "$unlabeled_source_value_id" "${TEST_PREFIX}-unlabeled-target-client" }