From 35dcbccb421b40e2dfbcd7e5fcdaa806baf1cc7f Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 1 Sep 2026 12:35:53 +0000 Subject: [PATCH 1/7] direct: revoke grants when the grants node is deleted and the parent stays DoDelete for grants was a deliberate no-op, so removing a grants block left every grant in place. It now revokes what is actually assigned, including out-of-band grants, because dropping the node means the empty list is what the bundle enforces. The no-op existed to keep `bundle destroy` working, so apply now distinguishes the two triggers: when the parent resource is deleted in the same plan it takes the child with it, and the child delete is applied as a state-only cleanup instead. Co-authored-by: Isaac --- bundle/deployplan/action.go | 11 ++++++ bundle/deployplan/action_test.go | 27 +++++++++++++ bundle/direct/bundle_apply.go | 36 +++++++++++++++-- bundle/direct/bundle_apply_test.go | 62 ++++++++++++++++++++++++++++++ bundle/direct/dresources/grants.go | 32 +++++++++++++-- 5 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 bundle/deployplan/action_test.go create mode 100644 bundle/direct/bundle_apply_test.go diff --git a/bundle/deployplan/action.go b/bundle/deployplan/action.go index e855dbb2197..1ffc6f2797b 100644 --- a/bundle/deployplan/action.go +++ b/bundle/deployplan/action.go @@ -25,6 +25,17 @@ func (a Action) IsChildResource() bool { return len(items) == 4 } +// ParentKey returns the key of the resource a child node hangs off, e.g. +// "resources.schemas.foo" for "resources.schemas.foo.grants". Returns "" if +// resourceKey is not a child node. +func ParentKey(resourceKey string) string { + items := strings.Split(resourceKey, ".") + if len(items) != 4 { + return "" + } + return strings.Join(items[:3], ".") +} + type ActionType string // Actions are ordered in increasing severity. diff --git a/bundle/deployplan/action_test.go b/bundle/deployplan/action_test.go new file mode 100644 index 00000000000..76164c2ce02 --- /dev/null +++ b/bundle/deployplan/action_test.go @@ -0,0 +1,27 @@ +package deployplan_test + +import ( + "testing" + + "github.com/databricks/cli/bundle/deployplan" + "github.com/stretchr/testify/assert" +) + +func TestParentKey(t *testing.T) { + tests := []struct { + resourceKey string + want string + }{ + {"resources.schemas.foo.grants", "resources.schemas.foo"}, + {"resources.jobs.foo.permissions", "resources.jobs.foo"}, + {"resources.schemas.foo", ""}, + {"resources.schemas", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.resourceKey, func(t *testing.T) { + assert.Equal(t, tt.want, deployplan.ParentKey(tt.resourceKey)) + }) + } +} diff --git a/bundle/direct/bundle_apply.go b/bundle/direct/bundle_apply.go index 424ae2bdec9..afef5e03b67 100644 --- a/bundle/direct/bundle_apply.go +++ b/bundle/direct/bundle_apply.go @@ -43,6 +43,10 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa return } + // Computed up front: the callback below holds a write lock on its own entry, so it + // cannot read another node's entry without risking a lock error. + parentDeleted := childDeletesWithDeletedParent(plan) + g.Run(defaultParallelism, func(resourceKey string, failedDependency *string) bool { entry, err := plan.WriteLockEntry(resourceKey) if err != nil { @@ -100,9 +104,10 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa } if action == deployplan.Delete { - if entry.Gone { - // Planning confirmed the resource is already deleted remotely; only - // remove it from the state, without calling the delete API. + if entry.Gone || parentDeleted[resourceKey] { + // Planning confirmed the resource is already deleted remotely, or the parent + // resource is going away and takes this node with it; only remove it from + // the state, without calling the delete API. err = b.StateDB.DeleteState(resourceKey) } else { err = d.Destroy(ctx, &b.StateDB) @@ -164,6 +169,31 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa }) } +// childDeletesWithDeletedParent returns the child nodes (grants, permissions) whose +// delete coincides with their parent resource being deleted. Deleting the parent removes +// them along with it, so the child's own delete has nothing left to do: it is applied as a +// state-only cleanup. This is what keeps `bundle destroy` working — issuing the child call +// against a resource that is about to disappear is what used to break it. +func childDeletesWithDeletedParent(plan *deployplan.Plan) map[string]bool { + result := make(map[string]bool) + + for key, entry := range plan.Plan { + if entry.Action != deployplan.Delete { + continue + } + parent := deployplan.ParentKey(key) + if parent == "" { + continue + } + parentEntry := plan.Plan[parent] + if parentEntry != nil && parentEntry.Action == deployplan.Delete { + result[key] = true + } + } + + return result +} + func (b *DeploymentBundle) LookupReferencePostDeploy(ctx context.Context, path *structpath.PathNode) (any, error) { targetResourceKey, fieldPath := splitResourcePath(path) targetGroup := config.GetResourceTypeFromKey(targetResourceKey) diff --git a/bundle/direct/bundle_apply_test.go b/bundle/direct/bundle_apply_test.go new file mode 100644 index 00000000000..d1e0838feb4 --- /dev/null +++ b/bundle/direct/bundle_apply_test.go @@ -0,0 +1,62 @@ +package direct + +import ( + "testing" + + "github.com/databricks/cli/bundle/deployplan" + "github.com/stretchr/testify/assert" +) + +func TestChildDeletesWithDeletedParent(t *testing.T) { + tests := []struct { + name string + plan map[string]*deployplan.PlanEntry + want map[string]bool + }{ + { + name: "parent deleted too", + plan: map[string]*deployplan.PlanEntry{ + "resources.schemas.foo": {Action: deployplan.Delete}, + "resources.schemas.foo.grants": {Action: deployplan.Delete}, + }, + want: map[string]bool{"resources.schemas.foo.grants": true}, + }, + { + name: "parent stays", + plan: map[string]*deployplan.PlanEntry{ + "resources.schemas.foo": {Action: deployplan.Skip}, + "resources.schemas.foo.grants": {Action: deployplan.Delete}, + }, + want: map[string]bool{}, + }, + { + name: "parent recreated, not deleted", + plan: map[string]*deployplan.PlanEntry{ + "resources.schemas.foo": {Action: deployplan.Recreate}, + "resources.schemas.foo.grants": {Action: deployplan.Delete}, + }, + want: map[string]bool{}, + }, + { + name: "parent not in plan", + plan: map[string]*deployplan.PlanEntry{ + "resources.schemas.foo.grants": {Action: deployplan.Delete}, + }, + want: map[string]bool{}, + }, + { + name: "top-level delete is not a child", + plan: map[string]*deployplan.PlanEntry{ + "resources.schemas.foo": {Action: deployplan.Delete}, + }, + want: map[string]bool{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := childDeletesWithDeletedParent(&deployplan.Plan{Plan: tt.plan}) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/bundle/direct/dresources/grants.go b/bundle/direct/dresources/grants.go index 5fd10f41046..fa718ec00ca 100644 --- a/bundle/direct/dresources/grants.go +++ b/bundle/direct/dresources/grants.go @@ -141,10 +141,36 @@ func (r *ResourceGrants) DoUpdate(ctx context.Context, _ string, state *GrantsSt return nil, err } +// DoDelete revokes every privilege currently assigned on the securable, including any +// granted out of band: dropping the grants node means the empty list is what the bundle +// enforces. Apply only reaches this when the securable itself stays — when the parent is +// deleted too, deleting it takes the grants with it and the node is a state-only cleanup. func (r *ResourceGrants) DoDelete(ctx context.Context, id string, _ *GrantsState) error { - // Similar to permissions, we do nothing there. - // We could delete all grants there, but it would be confusing to explain wrt permissions. - return nil + securableType, fullName, err := parseGrantsID(id) + if err != nil { + return err + } + + assignments, err := r.listGrants(ctx, securableType, fullName) + if err != nil { + return err + } + if len(assignments) == 0 { + return nil + } + + principals := make([]string, 0, len(assignments)) + for _, a := range assignments { + principals = append(principals, a.Principal) + } + slices.Sort(principals) + + _, err = r.client.Grants.Update(ctx, catalog.UpdatePermissions{ + SecurableType: securableType, + FullName: fullName, + Changes: buildGrantChanges(nil, principals), + }) + return err } func buildGrantChanges(desiredAssignments []catalog.PrivilegeAssignment, removedPrincipals []string) []catalog.PermissionsChange { From 58f8b6b83feed47dc3376e6d974ee0e5509e0a85 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 2 Sep 2026 00:31:41 +0000 Subject: [PATCH 2/7] Cover the three grants delete cases with one acceptance test Replaces the unit tests with an acceptance test that records the plan, the requests each deploy sends and the grants left on the schema for all three cases: the grants block removed, the list set to [], and the schema deleted along with its grants. The goldens are per-engine because the engines still disagree on the second case (direct plans an update, terraform a delete) and on the third, where terraform sends a redundant revoke for a securable it is about to drop. Also shortens childDeletesWithDeletedParent to willDeleteParent. Co-authored-by: Isaac --- .../bundles/grants-revoked-on-delete.md | 1 + .../delete_semantics/databricks.yml.tmpl | 9 +++ .../out.grants_empty_list.direct.txt | 1 + .../out.grants_empty_list.terraform.txt | 1 + .../out.grants_only_grants_deleted.direct.txt | 1 + ...t.grants_only_grants_deleted.terraform.txt | 1 + .../out.plan_empty_list.direct.txt | 3 + .../out.plan_empty_list.terraform.txt | 3 + .../out.plan_only_grants_deleted.direct.txt | 3 + ...out.plan_only_grants_deleted.terraform.txt | 3 + .../out.requests_empty_list.direct.json | 14 +++++ .../out.requests_empty_list.terraform.json | 14 +++++ ...t.requests_only_grants_deleted.direct.json | 14 +++++ ...equests_only_grants_deleted.terraform.json | 14 +++++ .../out.requests_parent_deleted.direct.json | 7 +++ ...out.requests_parent_deleted.terraform.json | 21 +++++++ .../schemas/delete_semantics/out.test.toml | 3 + .../schemas/delete_semantics/output.txt | 25 ++++++++ .../grants/schemas/delete_semantics/script | 45 ++++++++++++++ .../grants/schemas/delete_semantics/test.toml | 2 + bundle/deployplan/action_test.go | 27 -------- bundle/direct/bundle_apply.go | 12 ++-- bundle/direct/bundle_apply_test.go | 62 ------------------- 23 files changed, 190 insertions(+), 96 deletions(-) create mode 100644 .nextchanges/bundles/grants-revoked-on-delete.md create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.direct.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.terraform.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.direct.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.terraform.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.direct.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.terraform.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.direct.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.terraform.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.direct.json create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.terraform.json create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.direct.json create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.terraform.json create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.direct.json create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.terraform.json create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.test.toml create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/output.txt create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/script create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/test.toml delete mode 100644 bundle/deployplan/action_test.go delete mode 100644 bundle/direct/bundle_apply_test.go diff --git a/.nextchanges/bundles/grants-revoked-on-delete.md b/.nextchanges/bundles/grants-revoked-on-delete.md new file mode 100644 index 00000000000..c17379880db --- /dev/null +++ b/.nextchanges/bundles/grants-revoked-on-delete.md @@ -0,0 +1 @@ +direct: Removing a `grants` block now revokes the grants instead of leaving them in place ([#6474](https://github.com/databricks/cli/pull/6474)). diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/databricks.yml.tmpl b/acceptance/bundle/resources/grants/schemas/delete_semantics/databricks.yml.tmpl new file mode 100644 index 00000000000..90953232bb3 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/databricks.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: schema-grants-delete-semantics-$UNIQUE_NAME + +resources: + schemas: + grants_schema: + name: schema_delete_semantics_$UNIQUE_NAME + catalog_name: main + grants: [{ principal: deco-test-user@databricks.com, privileges: [USE_SCHEMA] }] # GRANTS diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.direct.txt new file mode 100644 index 00000000000..9534b2706f9 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.direct.txt @@ -0,0 +1 @@ +json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.terraform.txt new file mode 100644 index 00000000000..9534b2706f9 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.terraform.txt @@ -0,0 +1 @@ +json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.direct.txt new file mode 100644 index 00000000000..9534b2706f9 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.direct.txt @@ -0,0 +1 @@ +json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.terraform.txt new file mode 100644 index 00000000000..9534b2706f9 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.terraform.txt @@ -0,0 +1 @@ +json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.direct.txt new file mode 100644 index 00000000000..c0e5129ae5e --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.direct.txt @@ -0,0 +1,3 @@ +update schemas.grants_schema.grants + +Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.terraform.txt new file mode 100644 index 00000000000..c989c764bc7 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.terraform.txt @@ -0,0 +1,3 @@ +delete schemas.grants_schema.grants + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.direct.txt new file mode 100644 index 00000000000..c989c764bc7 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.direct.txt @@ -0,0 +1,3 @@ +delete schemas.grants_schema.grants + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.terraform.txt new file mode 100644 index 00000000000..c989c764bc7 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.terraform.txt @@ -0,0 +1,3 @@ +delete schemas.grants_schema.grants + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.direct.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.direct.json new file mode 100644 index 00000000000..6ed5aeeebbc --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.direct.json @@ -0,0 +1,14 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "ALL_PRIVILEGES" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.terraform.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.terraform.json new file mode 100644 index 00000000000..90123204a98 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.terraform.json @@ -0,0 +1,14 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "USE_SCHEMA" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.direct.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.direct.json new file mode 100644 index 00000000000..6ed5aeeebbc --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.direct.json @@ -0,0 +1,14 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "ALL_PRIVILEGES" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.terraform.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.terraform.json new file mode 100644 index 00000000000..90123204a98 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.terraform.json @@ -0,0 +1,14 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "USE_SCHEMA" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.direct.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.direct.json new file mode 100644 index 00000000000..2e1b8e2f571 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.direct.json @@ -0,0 +1,7 @@ +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/schemas/main.schema_delete_semantics_[UNIQUE_NAME]", + "q": { + "force": "true" + } +} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.terraform.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.terraform.json new file mode 100644 index 00000000000..d155cdab5bc --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.terraform.json @@ -0,0 +1,21 @@ +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/schemas/main.schema_delete_semantics_[UNIQUE_NAME]", + "q": { + "force": "true" + } +} +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "USE_SCHEMA" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.test.toml b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.test.toml new file mode 100644 index 00000000000..cef45fe553a --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.test.toml @@ -0,0 +1,3 @@ +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/output.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/output.txt new file mode 100644 index 00000000000..63eeac26980 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/output.txt @@ -0,0 +1,25 @@ + +=== Deploy the schema with one grant +>>> [CLI] bundle deploy -qq + +=== Case 1: the grants block is removed, the schema stays +>>> [CLI] bundle deploy -qq + +=== Case 2: the grants list is set to [] +>>> [CLI] bundle deploy -qq + +>>> [CLI] bundle deploy -qq + +=== Case 3: the schema and its grants are deleted together +>>> [CLI] bundle deploy -qq + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.grants_schema + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.grants_schema + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/schema-grants-delete-semantics-[UNIQUE_NAME]/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/script b/acceptance/bundle/resources/grants/schemas/delete_semantics/script new file mode 100644 index 00000000000..cf4447ff745 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/script @@ -0,0 +1,45 @@ +SCHEMA_FULL_NAME=main.schema_delete_semantics_$UNIQUE_NAME +GRANTS_LINE='grants: [{ principal: deco-test-user@databricks.com, privileges: [USE_SCHEMA] }] # GRANTS' + +envsubst < databricks.yml.tmpl > databricks.yml +cp databricks.yml databricks.yml.saved + +cleanup() { + errcode $CLI bundle destroy --auto-approve &> LOG.cleanup + rm -f out.requests.txt databricks.yml.saved tmp.yml +} +trap cleanup EXIT + +# Each case records the plan, the requests the deploy sends and the grants left on the +# schema. All three go to per-engine files because the engines classify a dropped grants +# node differently. +record() { + $CLI bundle plan > out.plan_$1.$DATABRICKS_BUNDLE_ENGINE.txt + trace $CLI bundle deploy -qq + print_requests.py //unity-catalog --sort > out.requests_$1.$DATABRICKS_BUNDLE_ENGINE.json + $CLI grants get schema "$SCHEMA_FULL_NAME" | gron.py --noindex | sort_lines.py --repl > out.grants_$1.$DATABRICKS_BUNDLE_ENGINE.txt +} + +restore_grant() { + cp databricks.yml.saved databricks.yml + trace $CLI bundle deploy -qq + rm -f out.requests.txt +} + +title "Deploy the schema with one grant" +trace $CLI bundle deploy -qq +rm -f out.requests.txt + +title "Case 1: the grants block is removed, the schema stays" +grep -v GRANTS databricks.yml > tmp.yml && mv tmp.yml databricks.yml +record only_grants_deleted + +title "Case 2: the grants list is set to []" +restore_grant +update_file.py databricks.yml "$GRANTS_LINE" 'grants: []' +record empty_list + +title "Case 3: the schema and its grants are deleted together" +restore_grant +trace $CLI bundle destroy --auto-approve +print_requests.py //unity-catalog --sort > out.requests_parent_deleted.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/test.toml b/acceptance/bundle/resources/grants/schemas/delete_semantics/test.toml new file mode 100644 index 00000000000..6b637eb9111 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/test.toml @@ -0,0 +1,2 @@ +# Six deploys plus a destroy; extra headroom for heavy parallel runs. +Timeout = '2m' diff --git a/bundle/deployplan/action_test.go b/bundle/deployplan/action_test.go deleted file mode 100644 index 76164c2ce02..00000000000 --- a/bundle/deployplan/action_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package deployplan_test - -import ( - "testing" - - "github.com/databricks/cli/bundle/deployplan" - "github.com/stretchr/testify/assert" -) - -func TestParentKey(t *testing.T) { - tests := []struct { - resourceKey string - want string - }{ - {"resources.schemas.foo.grants", "resources.schemas.foo"}, - {"resources.jobs.foo.permissions", "resources.jobs.foo"}, - {"resources.schemas.foo", ""}, - {"resources.schemas", ""}, - {"", ""}, - } - - for _, tt := range tests { - t.Run(tt.resourceKey, func(t *testing.T) { - assert.Equal(t, tt.want, deployplan.ParentKey(tt.resourceKey)) - }) - } -} diff --git a/bundle/direct/bundle_apply.go b/bundle/direct/bundle_apply.go index afef5e03b67..a9479e548d5 100644 --- a/bundle/direct/bundle_apply.go +++ b/bundle/direct/bundle_apply.go @@ -45,7 +45,7 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa // Computed up front: the callback below holds a write lock on its own entry, so it // cannot read another node's entry without risking a lock error. - parentDeleted := childDeletesWithDeletedParent(plan) + parentDeleted := willDeleteParent(plan) g.Run(defaultParallelism, func(resourceKey string, failedDependency *string) bool { entry, err := plan.WriteLockEntry(resourceKey) @@ -169,12 +169,10 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa }) } -// childDeletesWithDeletedParent returns the child nodes (grants, permissions) whose -// delete coincides with their parent resource being deleted. Deleting the parent removes -// them along with it, so the child's own delete has nothing left to do: it is applied as a -// state-only cleanup. This is what keeps `bundle destroy` working — issuing the child call -// against a resource that is about to disappear is what used to break it. -func childDeletesWithDeletedParent(plan *deployplan.Plan) map[string]bool { +// willDeleteParent returns the child nodes (grants, permissions) whose parent resource the +// same plan deletes: the parent takes them with it, so the child delete is applied as a +// state-only cleanup. Issuing it anyway is what used to break `bundle destroy`. +func willDeleteParent(plan *deployplan.Plan) map[string]bool { result := make(map[string]bool) for key, entry := range plan.Plan { diff --git a/bundle/direct/bundle_apply_test.go b/bundle/direct/bundle_apply_test.go deleted file mode 100644 index d1e0838feb4..00000000000 --- a/bundle/direct/bundle_apply_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package direct - -import ( - "testing" - - "github.com/databricks/cli/bundle/deployplan" - "github.com/stretchr/testify/assert" -) - -func TestChildDeletesWithDeletedParent(t *testing.T) { - tests := []struct { - name string - plan map[string]*deployplan.PlanEntry - want map[string]bool - }{ - { - name: "parent deleted too", - plan: map[string]*deployplan.PlanEntry{ - "resources.schemas.foo": {Action: deployplan.Delete}, - "resources.schemas.foo.grants": {Action: deployplan.Delete}, - }, - want: map[string]bool{"resources.schemas.foo.grants": true}, - }, - { - name: "parent stays", - plan: map[string]*deployplan.PlanEntry{ - "resources.schemas.foo": {Action: deployplan.Skip}, - "resources.schemas.foo.grants": {Action: deployplan.Delete}, - }, - want: map[string]bool{}, - }, - { - name: "parent recreated, not deleted", - plan: map[string]*deployplan.PlanEntry{ - "resources.schemas.foo": {Action: deployplan.Recreate}, - "resources.schemas.foo.grants": {Action: deployplan.Delete}, - }, - want: map[string]bool{}, - }, - { - name: "parent not in plan", - plan: map[string]*deployplan.PlanEntry{ - "resources.schemas.foo.grants": {Action: deployplan.Delete}, - }, - want: map[string]bool{}, - }, - { - name: "top-level delete is not a child", - plan: map[string]*deployplan.PlanEntry{ - "resources.schemas.foo": {Action: deployplan.Delete}, - }, - want: map[string]bool{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := childDeletesWithDeletedParent(&deployplan.Plan{Plan: tt.plan}) - assert.Equal(t, tt.want, got) - }) - } -} From dcd6a888fc21ffc28f7d82e855a4d09a145b5735 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 2 Sep 2026 01:21:17 +0000 Subject: [PATCH 3/7] Fix lint and consolidate the acceptance test output exhaustruct wants every field of catalog.UpdatePermissions listed, matching DoUpdate. The test wrote 16 golden files, one per case per engine. All three cases now append to a single per-engine file, so the whole comparison reads top to bottom in one place and the directory holds two goldens instead of sixteen. Co-authored-by: Isaac --- .../schemas/delete_semantics/out.direct.txt | 46 ++++++++++++++ .../out.grants_empty_list.direct.txt | 1 - .../out.grants_empty_list.terraform.txt | 1 - .../out.grants_only_grants_deleted.direct.txt | 1 - ...t.grants_only_grants_deleted.terraform.txt | 1 - .../out.plan_empty_list.direct.txt | 3 - .../out.plan_empty_list.terraform.txt | 3 - .../out.plan_only_grants_deleted.direct.txt | 3 - ...out.plan_only_grants_deleted.terraform.txt | 3 - .../out.requests_empty_list.direct.json | 14 ----- .../out.requests_empty_list.terraform.json | 14 ----- ...t.requests_only_grants_deleted.direct.json | 14 ----- ...equests_only_grants_deleted.terraform.json | 14 ----- .../out.requests_parent_deleted.direct.json | 7 --- ...out.requests_parent_deleted.terraform.json | 21 ------- .../delete_semantics/out.terraform.txt | 60 +++++++++++++++++++ .../schemas/delete_semantics/output.txt | 6 +- .../grants/schemas/delete_semantics/script | 26 ++++---- bundle/direct/bundle_apply.go | 2 +- bundle/direct/dresources/grants.go | 8 ++- 20 files changed, 131 insertions(+), 117 deletions(-) create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.direct.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.terraform.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.direct.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.terraform.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.direct.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.terraform.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.direct.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.terraform.txt delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.direct.json delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.terraform.json delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.direct.json delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.terraform.json delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.direct.json delete mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.terraform.json create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/out.terraform.txt diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt new file mode 100644 index 00000000000..34fca6d58d0 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt @@ -0,0 +1,46 @@ +=== only the grants node is deleted +delete schemas.grants_schema.grants + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "ALL_PRIVILEGES" + ] + } + ] + } +} +json = {}; +=== the grants list is empty +update schemas.grants_schema.grants + +Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "ALL_PRIVILEGES" + ] + } + ] + } +} +json = {}; +=== the parent is deleted too +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/schemas/main.schema_delete_semantics_[UNIQUE_NAME]", + "q": { + "force": "true" + } +} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.direct.txt deleted file mode 100644 index 9534b2706f9..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.direct.txt +++ /dev/null @@ -1 +0,0 @@ -json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.terraform.txt deleted file mode 100644 index 9534b2706f9..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_empty_list.terraform.txt +++ /dev/null @@ -1 +0,0 @@ -json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.direct.txt deleted file mode 100644 index 9534b2706f9..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.direct.txt +++ /dev/null @@ -1 +0,0 @@ -json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.terraform.txt deleted file mode 100644 index 9534b2706f9..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.grants_only_grants_deleted.terraform.txt +++ /dev/null @@ -1 +0,0 @@ -json = {}; diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.direct.txt deleted file mode 100644 index c0e5129ae5e..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.direct.txt +++ /dev/null @@ -1,3 +0,0 @@ -update schemas.grants_schema.grants - -Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.terraform.txt deleted file mode 100644 index c989c764bc7..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_empty_list.terraform.txt +++ /dev/null @@ -1,3 +0,0 @@ -delete schemas.grants_schema.grants - -Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.direct.txt deleted file mode 100644 index c989c764bc7..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.direct.txt +++ /dev/null @@ -1,3 +0,0 @@ -delete schemas.grants_schema.grants - -Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.terraform.txt deleted file mode 100644 index c989c764bc7..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.plan_only_grants_deleted.terraform.txt +++ /dev/null @@ -1,3 +0,0 @@ -delete schemas.grants_schema.grants - -Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.direct.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.direct.json deleted file mode 100644 index 6ed5aeeebbc..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.direct.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "method": "PATCH", - "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", - "body": { - "changes": [ - { - "principal": "deco-test-user@databricks.com", - "remove": [ - "ALL_PRIVILEGES" - ] - } - ] - } -} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.terraform.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.terraform.json deleted file mode 100644 index 90123204a98..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_empty_list.terraform.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "method": "PATCH", - "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", - "body": { - "changes": [ - { - "principal": "deco-test-user@databricks.com", - "remove": [ - "USE_SCHEMA" - ] - } - ] - } -} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.direct.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.direct.json deleted file mode 100644 index 6ed5aeeebbc..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.direct.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "method": "PATCH", - "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", - "body": { - "changes": [ - { - "principal": "deco-test-user@databricks.com", - "remove": [ - "ALL_PRIVILEGES" - ] - } - ] - } -} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.terraform.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.terraform.json deleted file mode 100644 index 90123204a98..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_only_grants_deleted.terraform.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "method": "PATCH", - "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", - "body": { - "changes": [ - { - "principal": "deco-test-user@databricks.com", - "remove": [ - "USE_SCHEMA" - ] - } - ] - } -} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.direct.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.direct.json deleted file mode 100644 index 2e1b8e2f571..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.direct.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "method": "DELETE", - "path": "/api/2.1/unity-catalog/schemas/main.schema_delete_semantics_[UNIQUE_NAME]", - "q": { - "force": "true" - } -} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.terraform.json b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.terraform.json deleted file mode 100644 index d155cdab5bc..00000000000 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.requests_parent_deleted.terraform.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "method": "DELETE", - "path": "/api/2.1/unity-catalog/schemas/main.schema_delete_semantics_[UNIQUE_NAME]", - "q": { - "force": "true" - } -} -{ - "method": "PATCH", - "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", - "body": { - "changes": [ - { - "principal": "deco-test-user@databricks.com", - "remove": [ - "USE_SCHEMA" - ] - } - ] - } -} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.terraform.txt new file mode 100644 index 00000000000..8b47185fe93 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.terraform.txt @@ -0,0 +1,60 @@ +=== only the grants node is deleted +delete schemas.grants_schema.grants + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "USE_SCHEMA" + ] + } + ] + } +} +json = {}; +=== the grants list is empty +delete schemas.grants_schema.grants + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "USE_SCHEMA" + ] + } + ] + } +} +json = {}; +=== the parent is deleted too +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/schemas/main.schema_delete_semantics_[UNIQUE_NAME]", + "q": { + "force": "true" + } +} +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "USE_SCHEMA" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/output.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/output.txt index 63eeac26980..a20784ffd26 100644 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/output.txt +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/output.txt @@ -5,14 +5,16 @@ === Case 1: the grants block is removed, the schema stays >>> [CLI] bundle deploy -qq -=== Case 2: the grants list is set to [] +=== Put the grant back >>> [CLI] bundle deploy -qq +=== Case 2: the grants list is set to [] >>> [CLI] bundle deploy -qq -=== Case 3: the schema and its grants are deleted together +=== Put the grant back >>> [CLI] bundle deploy -qq +=== Case 3: the schema and its grants are deleted together >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: delete resources.schemas.grants_schema diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/script b/acceptance/bundle/resources/grants/schemas/delete_semantics/script index cf4447ff745..ebc4b61c57c 100644 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/script +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/script @@ -1,6 +1,10 @@ SCHEMA_FULL_NAME=main.schema_delete_semantics_$UNIQUE_NAME GRANTS_LINE='grants: [{ principal: deco-test-user@databricks.com, privileges: [USE_SCHEMA] }] # GRANTS' +# The plan, the requests and the leftover grants all go to one per-engine file: the engines +# disagree on how a dropped grants node is classified and on what they send for it. +ENGINE_OUT=out.$DATABRICKS_BUNDLE_ENGINE.txt + envsubst < databricks.yml.tmpl > databricks.yml cp databricks.yml databricks.yml.saved @@ -10,17 +14,16 @@ cleanup() { } trap cleanup EXIT -# Each case records the plan, the requests the deploy sends and the grants left on the -# schema. All three go to per-engine files because the engines classify a dropped grants -# node differently. record() { - $CLI bundle plan > out.plan_$1.$DATABRICKS_BUNDLE_ENGINE.txt + echo "=== $1" >> $ENGINE_OUT + $CLI bundle plan >> $ENGINE_OUT trace $CLI bundle deploy -qq - print_requests.py //unity-catalog --sort > out.requests_$1.$DATABRICKS_BUNDLE_ENGINE.json - $CLI grants get schema "$SCHEMA_FULL_NAME" | gron.py --noindex | sort_lines.py --repl > out.grants_$1.$DATABRICKS_BUNDLE_ENGINE.txt + print_requests.py //unity-catalog --sort >> $ENGINE_OUT + $CLI grants get schema "$SCHEMA_FULL_NAME" | gron.py --noindex | sort_lines.py --repl >> $ENGINE_OUT } restore_grant() { + title "Put the grant back" cp databricks.yml.saved databricks.yml trace $CLI bundle deploy -qq rm -f out.requests.txt @@ -32,14 +35,15 @@ rm -f out.requests.txt title "Case 1: the grants block is removed, the schema stays" grep -v GRANTS databricks.yml > tmp.yml && mv tmp.yml databricks.yml -record only_grants_deleted +record "only the grants node is deleted" -title "Case 2: the grants list is set to []" restore_grant +title "Case 2: the grants list is set to []" update_file.py databricks.yml "$GRANTS_LINE" 'grants: []' -record empty_list +record "the grants list is empty" -title "Case 3: the schema and its grants are deleted together" restore_grant +title "Case 3: the schema and its grants are deleted together" +echo "=== the parent is deleted too" >> $ENGINE_OUT trace $CLI bundle destroy --auto-approve -print_requests.py //unity-catalog --sort > out.requests_parent_deleted.$DATABRICKS_BUNDLE_ENGINE.json +print_requests.py //unity-catalog --sort >> $ENGINE_OUT diff --git a/bundle/direct/bundle_apply.go b/bundle/direct/bundle_apply.go index a9479e548d5..15e3077281a 100644 --- a/bundle/direct/bundle_apply.go +++ b/bundle/direct/bundle_apply.go @@ -171,7 +171,7 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa // willDeleteParent returns the child nodes (grants, permissions) whose parent resource the // same plan deletes: the parent takes them with it, so the child delete is applied as a -// state-only cleanup. Issuing it anyway is what used to break `bundle destroy`. +// state-only cleanup. func willDeleteParent(plan *deployplan.Plan) map[string]bool { result := make(map[string]bool) diff --git a/bundle/direct/dresources/grants.go b/bundle/direct/dresources/grants.go index fa718ec00ca..3bd69befca1 100644 --- a/bundle/direct/dresources/grants.go +++ b/bundle/direct/dresources/grants.go @@ -166,9 +166,11 @@ func (r *ResourceGrants) DoDelete(ctx context.Context, id string, _ *GrantsState slices.Sort(principals) _, err = r.client.Grants.Update(ctx, catalog.UpdatePermissions{ - SecurableType: securableType, - FullName: fullName, - Changes: buildGrantChanges(nil, principals), + SecurableType: securableType, + FullName: fullName, + Changes: buildGrantChanges(nil, principals), + OmitPermissionsInResponse: false, + ForceSendFields: nil, }) return err } From 2a918866e56d1b8db0b63b50eb31ad53593a1a84 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 2 Sep 2026 02:01:41 +0000 Subject: [PATCH 4/7] Record the plan for the parent-deleted case too `bundle destroy` hides child nodes from its banner, so the case where the parent goes away never showed what the grants node was planned as. Planning against a config with the schema removed names both nodes and their action, which is the same shape destroy applies: both are a delete, and only the parent's reaches the API on direct. Co-authored-by: Isaac --- .../schemas/delete_semantics/databricks.empty.yml.tmpl | 2 ++ .../resources/grants/schemas/delete_semantics/out.direct.txt | 4 ++++ .../grants/schemas/delete_semantics/out.terraform.txt | 4 ++++ .../bundle/resources/grants/schemas/delete_semantics/script | 5 +++++ 4 files changed, 15 insertions(+) create mode 100644 acceptance/bundle/resources/grants/schemas/delete_semantics/databricks.empty.yml.tmpl diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/databricks.empty.yml.tmpl b/acceptance/bundle/resources/grants/schemas/delete_semantics/databricks.empty.yml.tmpl new file mode 100644 index 00000000000..c111e6825dc --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/databricks.empty.yml.tmpl @@ -0,0 +1,2 @@ +bundle: + name: schema-grants-delete-semantics-$UNIQUE_NAME diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt index 34fca6d58d0..38e54270f56 100644 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt @@ -37,6 +37,10 @@ Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged } json = {}; === the parent is deleted too +delete schemas.grants_schema +delete schemas.grants_schema.grants + +Plan: 0 to add, 0 to change, 2 to delete, 0 unchanged { "method": "DELETE", "path": "/api/2.1/unity-catalog/schemas/main.schema_delete_semantics_[UNIQUE_NAME]", diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.terraform.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.terraform.txt index 8b47185fe93..e0df82b0cb5 100644 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.terraform.txt +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.terraform.txt @@ -37,6 +37,10 @@ Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged } json = {}; === the parent is deleted too +delete schemas.grants_schema +delete schemas.grants_schema.grants + +Plan: 0 to add, 0 to change, 2 to delete, 0 unchanged { "method": "DELETE", "path": "/api/2.1/unity-catalog/schemas/main.schema_delete_semantics_[UNIQUE_NAME]", diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/script b/acceptance/bundle/resources/grants/schemas/delete_semantics/script index ebc4b61c57c..4d559a72be9 100644 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/script +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/script @@ -45,5 +45,10 @@ record "the grants list is empty" restore_grant title "Case 3: the schema and its grants are deleted together" echo "=== the parent is deleted too" >> $ENGINE_OUT +# Planned against a config with the schema gone, so the plan names both nodes and the +# action each one gets. `bundle destroy` applies the same shape but its banner hides +# child nodes, so it never shows what happens to the grants. +envsubst < databricks.empty.yml.tmpl > databricks.yml +$CLI bundle plan >> $ENGINE_OUT trace $CLI bundle destroy --auto-approve print_requests.py //unity-catalog --sort >> $ENGINE_OUT From 3e9e17690597e6a033ec1e60879d8f54088c8d31 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Thu, 3 Sep 2026 12:55:59 +0000 Subject: [PATCH 5/7] direct: plan an emptied list as a delete, not an update An empty list and a removed block are the same request, so they now get the same action. Emptying a node planned an update that revoked and then dropped the state entry, so the next plan saw no state and skipped the node - the list stopped being enforced after the first deploy, and out-of-band grants survived. It also left apply reporting a resource that no longer had state. The node goes back to existingKeys instead, which plans a delete: DoDelete revokes, and the entry goes away because the node really is gone. Terraform already classified it this way, so the engines now agree. Co-authored-by: Isaac --- .../schemas/delete_semantics/out.direct.txt | 4 ++-- bundle/direct/apply.go | 19 ++--------------- bundle/direct/bundle_plan.go | 21 +++++++++++-------- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt index 38e54270f56..eb1304606c0 100644 --- a/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt +++ b/acceptance/bundle/resources/grants/schemas/delete_semantics/out.direct.txt @@ -18,9 +18,9 @@ Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged } json = {}; === the grants list is empty -update schemas.grants_schema.grants +delete schemas.grants_schema.grants -Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged { "method": "PATCH", "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_delete_semantics_[UNIQUE_NAME]", diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index 6b2fffe9a16..8061cc6da64 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -159,24 +159,9 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return err } - empty, err := d.Adapter.IsEmptyState(newState) + err = d.saveState(db, id, newState, d.DependsOn) if err != nil { - return err - } - - if empty { - // The update emptied the resource out (e.g. all grants revoked). Keeping an entry - // would report the node as tracked-and-unchanged forever, while a fresh deploy of - // the same config plans no node at all; drop it so the two agree. - err = db.DeleteState(d.ResourceKey) - if err != nil { - return fmt.Errorf("deleting state id=%s: %w", id, err) - } - } else { - err = d.saveState(db, id, newState, d.DependsOn) - if err != nil { - return fmt.Errorf("saving state id=%s: %w", id, err) - } + return fmt.Errorf("saving state id=%s: %w", id, err) } waitRemoteState, err := retryOnTransient(ctx, func() (any, error) { diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 453e588187b..92f86f8055f 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -992,16 +992,19 @@ func (b *DeploymentBundle) makePlan(ctx context.Context, configRoot *config.Root return nil, fmt.Errorf("%s: %w", prefix, err) } - // New nodes only: a node with state must stay in the plan, otherwise emptying it plans nothing. - // Apply drops the state entry once the node is empty, so it is skipped from then on. - if _, hasState := db.State[node]; !hasState { - empty, err := adapter.IsEmptyState(newStateConfig) - if err != nil { - return nil, fmt.Errorf("%s: %w", prefix, err) - } - if empty { - continue + // An empty state describes no resource, so the node is not planned as one. A node with + // no state is simply left out: there is nothing to create. A node that has state goes + // back to existingKeys and is planned as a delete, which revokes and stops tracking it - + // emptying the list is the same request as removing the block. + empty, err := adapter.IsEmptyState(newStateConfig) + if err != nil { + return nil, fmt.Errorf("%s: %w", prefix, err) + } + if empty { + if stateEntry, hasState := db.State[node]; hasState { + existingKeys[node] = stateEntry } + continue } // Note, we're extracting references in input config but resolving them in newState.Config which is PrepareState(inputConfig) From 034bcdc88905547e38fee6bfcf4bc95cc963b169 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Thu, 3 Sep 2026 15:35:49 +0000 Subject: [PATCH 6/7] direct: revoke permissions down to the owner when the node is deleted Emptying a permissions list, or removing the block, was silently ignored: DoDelete did nothing, so a permission the bundle had granted stayed granted, and anything added out of band stayed too. Both now revoke, which is what the config asks for. The owner survives because the API insists on it: a Set without exactly one IS_OWNER is rejected, so owner-only is the floor rather than an empty list. It is read from the current permissions instead of assumed to be the caller - the persisted state never holds the owner, and an object can be owned by a service principal or have been handed over since it was deployed. IsEmptyState makes an emptied list take the same path as a removed block, matching grants. Nothing changes for a first deploy that starts out empty: that node has no state and is still left unplanned. The parent-deleted case needs nothing new - the plan already applies a child delete as a state-only cleanup when the parent goes away in the same run. Co-authored-by: Isaac --- .../bundles/permissions-revoked-on-delete.md | 2 + .../jobs/revoke_semantics/databricks.yml.tmpl | 14 +++++ .../jobs/revoke_semantics/notebook.py | 2 + .../jobs/revoke_semantics/out.test.toml | 3 + .../jobs/revoke_semantics/output.txt | 57 +++++++++++++++++++ .../permissions/jobs/revoke_semantics/script | 39 +++++++++++++ .../jobs/revoke_semantics/test.toml | 4 ++ bundle/direct/dresources/permissions.go | 38 ++++++++++++- 8 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 .nextchanges/bundles/permissions-revoked-on-delete.md create mode 100644 acceptance/bundle/resources/permissions/jobs/revoke_semantics/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/permissions/jobs/revoke_semantics/notebook.py create mode 100644 acceptance/bundle/resources/permissions/jobs/revoke_semantics/out.test.toml create mode 100644 acceptance/bundle/resources/permissions/jobs/revoke_semantics/output.txt create mode 100644 acceptance/bundle/resources/permissions/jobs/revoke_semantics/script create mode 100644 acceptance/bundle/resources/permissions/jobs/revoke_semantics/test.toml diff --git a/.nextchanges/bundles/permissions-revoked-on-delete.md b/.nextchanges/bundles/permissions-revoked-on-delete.md new file mode 100644 index 00000000000..2fda44130dc --- /dev/null +++ b/.nextchanges/bundles/permissions-revoked-on-delete.md @@ -0,0 +1,2 @@ +Emptying a `permissions` list, or removing the block, now revokes every permission except the +object owner, which the API requires. Previously both were silently ignored. diff --git a/acceptance/bundle/resources/permissions/jobs/revoke_semantics/databricks.yml.tmpl b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/databricks.yml.tmpl new file mode 100644 index 00000000000..7ebacbbca29 --- /dev/null +++ b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/databricks.yml.tmpl @@ -0,0 +1,14 @@ +bundle: + name: job-permissions-revoke-$UNIQUE_NAME + +resources: + jobs: + foo: + name: job_revoke_semantics_$UNIQUE_NAME + tasks: + - task_key: main + notebook_task: + notebook_path: ./notebook.py + permissions: # PERMISSIONS + - level: CAN_VIEW # PERMISSIONS + user_name: deco-test-user@databricks.com # PERMISSIONS diff --git a/acceptance/bundle/resources/permissions/jobs/revoke_semantics/notebook.py b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/notebook.py new file mode 100644 index 00000000000..4914a7436d9 --- /dev/null +++ b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/notebook.py @@ -0,0 +1,2 @@ +# Databricks notebook source +print("hello") diff --git a/acceptance/bundle/resources/permissions/jobs/revoke_semantics/out.test.toml b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/out.test.toml new file mode 100644 index 00000000000..9b8ddfcd2bd --- /dev/null +++ b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/out.test.toml @@ -0,0 +1,3 @@ +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.DMS = [""] diff --git a/acceptance/bundle/resources/permissions/jobs/revoke_semantics/output.txt b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/output.txt new file mode 100644 index 00000000000..979fe3d941c --- /dev/null +++ b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/output.txt @@ -0,0 +1,57 @@ + +=== Deploy with one grant to a non-owner +>>> [CLI] bundle deploy -qq + +>>> acl +[ + { + "levels": [ + "CAN_VIEW" + ], + "name": "deco-test-user@databricks.com" + }, + { + "levels": [ + "IS_OWNER" + ], + "name": "[USERNAME]" + } +] + +=== Empty the list: everything but the owner is revoked +>>> [CLI] bundle plan +delete jobs.foo.permissions + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged + +>>> [CLI] bundle deploy -qq + +>>> acl +[ + { + "levels": [ + "IS_OWNER" + ], + "name": "[USERNAME]" + } +] + +=== Put the grant back, then remove the block entirely: same outcome +>>> [CLI] bundle deploy -qq + +>>> [CLI] bundle plan +delete jobs.foo.permissions + +Plan: 0 to add, 0 to change, 1 to delete, 1 unchanged + +>>> [CLI] bundle deploy -qq + +>>> acl +[ + { + "levels": [ + "IS_OWNER" + ], + "name": "[USERNAME]" + } +] diff --git a/acceptance/bundle/resources/permissions/jobs/revoke_semantics/script b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/script new file mode 100644 index 00000000000..d86406ba556 --- /dev/null +++ b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/script @@ -0,0 +1,39 @@ +envsubst < databricks.yml.tmpl > databricks.yml +cp databricks.yml databricks.yml.saved + +cleanup() { + errcode $CLI bundle destroy --auto-approve &> LOG.cleanup + rm -f out.requests.txt databricks.yml.saved tmp.yml +} +trap cleanup EXIT + +# The API rejects a Set without exactly one IS_OWNER, so an emptied list revokes down to the +# owner rather than to nothing. The testserver does not enforce that, which is why this runs +# on cloud: the read below is the assertion. +acl() { + $CLI api get "/api/2.0/permissions/jobs/$JOB_ID" | + jq -S '[.access_control_list[] + | select(any(.all_permissions[]; .inherited == false)) + | {name: (.user_name // .service_principal_name // .group_name), + levels: [.all_permissions[] | select(.inherited == false) | .permission_level]}]' +} + +title "Deploy with one grant to a non-owner" +trace $CLI bundle deploy -qq +JOB_ID=$(read_id.py foo) +trace acl + +title "Empty the list: everything but the owner is revoked" +update_file.py databricks.yml 'permissions: # PERMISSIONS' 'permissions: []' +sed -i '/# PERMISSIONS/d' databricks.yml +trace $CLI bundle plan +trace $CLI bundle deploy -qq +trace acl + +title "Put the grant back, then remove the block entirely: same outcome" +cp databricks.yml.saved databricks.yml +trace $CLI bundle deploy -qq +sed -i '/# PERMISSIONS/d' databricks.yml +trace $CLI bundle plan +trace $CLI bundle deploy -qq +trace acl diff --git a/acceptance/bundle/resources/permissions/jobs/revoke_semantics/test.toml b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/test.toml new file mode 100644 index 00000000000..2c49e7c7361 --- /dev/null +++ b/acceptance/bundle/resources/permissions/jobs/revoke_semantics/test.toml @@ -0,0 +1,4 @@ +# The API requires exactly one IS_OWNER on every Set, so the revoke floor is owner-only. +# The testserver does not enforce that, so this only means anything against a real workspace. +Cloud = true +EnvMatrix.DMS = [""] diff --git a/bundle/direct/dresources/permissions.go b/bundle/direct/dresources/permissions.go index 2ab265d29f1..08def8f4baf 100644 --- a/bundle/direct/dresources/permissions.go +++ b/bundle/direct/dresources/permissions.go @@ -271,7 +271,41 @@ func (r *ResourcePermissions) DoUpdate(ctx context.Context, _ string, newState * // it themselves. Trying to fix permissions back requires // - making assumptions on what it should look like // - storing current user somewhere or storing original permissions somewhere +// IsEmptyState reports an empty permissions list as no resource at all, so emptying it is +// planned as a delete: the same request as removing the block, and the same outcome - every +// permission but the owner revoked. +func (*ResourcePermissions) IsEmptyState(state *PermissionsState) bool { + return len(state.EmbeddedSlice) == 0 +} + +// DoDelete revokes every permission the object carries except its owner, which the API +// requires: a Set without exactly one IS_OWNER is rejected. Apply only reaches this when the +// object itself stays - when the parent is deleted too, deleting it takes the permissions with +// it and the node is a state-only cleanup. +// +// The owner comes from the current permissions rather than the persisted state, which only +// holds what the bundle set and never the owner. Reading it also keeps the owner as it is now, +// which is not always the caller: an object can be owned by a service principal, or have been +// handed over since it was deployed. func (r *ResourcePermissions) DoDelete(ctx context.Context, id string, _ *PermissionsState) error { - // intentional noop - return nil + current, err := r.DoRead(ctx, id) + if err != nil { + return err + } + + ownerOnly := &PermissionsState{ObjectID: id, EmbeddedSlice: nil} + for _, p := range current.EmbeddedSlice { + if p.Level == iam.PermissionLevelIsOwner { + ownerOnly.EmbeddedSlice = append(ownerOnly.EmbeddedSlice, p) + } + } + + // Nothing to revoke down to: an object with no owner takes no Set, and one with only its + // owner is already there. + if len(ownerOnly.EmbeddedSlice) == 0 || len(current.EmbeddedSlice) == len(ownerOnly.EmbeddedSlice) { + return nil + } + + _, err = r.DoUpdate(ctx, id, ownerOnly, nil) + return err } From 9ef6d6baf2afa06700fe2acafb3d4fff9d66fde8 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Thu, 3 Sep 2026 15:38:35 +0000 Subject: [PATCH 7/7] Call the revoke-on-delete change out as notable It changes what a deploy does to grants and permissions that are already there, including ones the bundle did not create, so it belongs above the per-area sections rather than in two separate entries under Bundles. Co-authored-by: Isaac --- .nextchanges/bundles/grants-revoked-on-delete.md | 1 - .../bundles/permissions-revoked-on-delete.md | 2 -- .../grants-and-permissions-revoked-on-delete.md | 12 ++++++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) delete mode 100644 .nextchanges/bundles/grants-revoked-on-delete.md delete mode 100644 .nextchanges/bundles/permissions-revoked-on-delete.md create mode 100644 .nextchanges/notable-changes/grants-and-permissions-revoked-on-delete.md diff --git a/.nextchanges/bundles/grants-revoked-on-delete.md b/.nextchanges/bundles/grants-revoked-on-delete.md deleted file mode 100644 index c17379880db..00000000000 --- a/.nextchanges/bundles/grants-revoked-on-delete.md +++ /dev/null @@ -1 +0,0 @@ -direct: Removing a `grants` block now revokes the grants instead of leaving them in place ([#6474](https://github.com/databricks/cli/pull/6474)). diff --git a/.nextchanges/bundles/permissions-revoked-on-delete.md b/.nextchanges/bundles/permissions-revoked-on-delete.md deleted file mode 100644 index 2fda44130dc..00000000000 --- a/.nextchanges/bundles/permissions-revoked-on-delete.md +++ /dev/null @@ -1,2 +0,0 @@ -Emptying a `permissions` list, or removing the block, now revokes every permission except the -object owner, which the API requires. Previously both were silently ignored. diff --git a/.nextchanges/notable-changes/grants-and-permissions-revoked-on-delete.md b/.nextchanges/notable-changes/grants-and-permissions-revoked-on-delete.md new file mode 100644 index 00000000000..672945b1e92 --- /dev/null +++ b/.nextchanges/notable-changes/grants-and-permissions-revoked-on-delete.md @@ -0,0 +1,12 @@ +Deleting a `grants` or `permissions` block, or emptying it to `[]`, now revokes what it +granted. Both were previously ignored: the grant or permission stayed in place, and so did +anything added outside the bundle. An empty list and a removed block are the same request, +and both are now applied as a delete ([#6474](https://github.com/databricks/cli/pull/6474)). + +`grants: []` revokes everything. `permissions: []` revokes everything but the object owner, +which the API requires on every update. The owner is read from the object rather than assumed +to be whoever deploys, so an object owned by a service principal, or handed over since it was +deployed, keeps the owner it has. + +Nothing changes for a bundle that has always had an empty list, and deleting the resource a +block belongs to is unaffected.