From d6a986bb49b8b44f9dd1e56fb63a5e14e587ee66 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 23 Jul 2026 17:41:59 +0200 Subject: [PATCH 1/3] Honor explicit permissions: [] as managed with default IS_OWNER ApplyBundlePermissions collapsed an empty permissions slice to nil, which FixPermissions then skipped (AsSequence fails on nil), so an explicit permissions: [] never got the default IS_OWNER for the current user. It was indistinguishable from omitting the block entirely. Track whether the permissions key was present and only collapse to nil when it was absent. An explicit empty list now survives as an empty sequence, so FixPermissions adds IS_OWNER, matching both a non-empty list and Terraform. The jobs/pipelines empty_list fixtures documented this as a known bug; that comment is now resolved. Co-authored-by: Isaac --- .../bundles/permissions-empty-list.md | 1 + .../jobs/empty_list/databricks.yml | 2 +- .../jobs/empty_list/out.plan.direct.json | 23 ++++++++++++ .../jobs/empty_list/out.plan.terraform.json | 3 ++ .../out.requests.deploy.direct.json | 12 ++++++ .../out.requests.deploy.terraform.json | 12 ++++++ .../out.requests.destroy.terraform.json | 12 ++++++ .../permissions/jobs/empty_list/output.txt | 7 +++- .../update/out.plan_set_empty.direct.json | 37 ++++++++++++++++++- .../update/out.plan_set_empty.terraform.json | 2 +- .../out.requests_destroy.terraform.json | 12 ++++++ .../update/out.requests_set_empty.direct.json | 12 ++++++ .../bundle/resources/permissions/output.txt | 37 ++++++++++++++----- .../pipelines/empty_list/databricks.yml | 2 +- .../pipelines/empty_list/out.plan.direct.json | 23 ++++++++++++ .../empty_list/out.plan.terraform.json | 3 ++ .../out.requests.deploy.direct.json | 12 ++++++ .../out.requests.deploy.terraform.json | 12 ++++++ .../pipelines/empty_list/output.txt | 7 +++- .../apply_bundle_permissions.go | 7 +++- 20 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 .nextchanges/bundles/permissions-empty-list.md diff --git a/.nextchanges/bundles/permissions-empty-list.md b/.nextchanges/bundles/permissions-empty-list.md new file mode 100644 index 0000000000..a42cb5eb14 --- /dev/null +++ b/.nextchanges/bundles/permissions-empty-list.md @@ -0,0 +1 @@ +An explicit `permissions: []` on a resource is now managed like a non-empty list: the current user is added as the default owner (`IS_OWNER`), matching Terraform. Previously an empty list was treated as if the block were absent. diff --git a/acceptance/bundle/resources/permissions/jobs/empty_list/databricks.yml b/acceptance/bundle/resources/permissions/jobs/empty_list/databricks.yml index 79f03b68bd..56475c0d2e 100644 --- a/acceptance/bundle/resources/permissions/jobs/empty_list/databricks.yml +++ b/acceptance/bundle/resources/permissions/jobs/empty_list/databricks.yml @@ -10,5 +10,5 @@ resources: notebook_task: notebook_path: /Workspace/Users/user@example.com/notebook source: WORKSPACE - # this is interpreted the same as nil, but it's IMO a bug; it should do default IS_OWNER setting + # An explicit empty list is managed: FixPermissions adds the default IS_OWNER for the current user. permissions: [] diff --git a/acceptance/bundle/resources/permissions/jobs/empty_list/out.plan.direct.json b/acceptance/bundle/resources/permissions/jobs/empty_list/out.plan.direct.json index 0ec914589b..88e1e115a9 100644 --- a/acceptance/bundle/resources/permissions/jobs/empty_list/out.plan.direct.json +++ b/acceptance/bundle/resources/permissions/jobs/empty_list/out.plan.direct.json @@ -28,6 +28,29 @@ ] } } + }, + "resources.jobs.foo.permissions": { + "depends_on": [ + { + "node": "resources.jobs.foo", + "label": "${resources.jobs.foo.id}" + } + ], + "action": "create", + "new_state": { + "value": { + "object_id": "", + "__embed__": [ + { + "level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + }, + "vars": { + "object_id": "/jobs/${resources.jobs.foo.id}" + } + } } } } diff --git a/acceptance/bundle/resources/permissions/jobs/empty_list/out.plan.terraform.json b/acceptance/bundle/resources/permissions/jobs/empty_list/out.plan.terraform.json index 5a250db78d..0db00e350f 100644 --- a/acceptance/bundle/resources/permissions/jobs/empty_list/out.plan.terraform.json +++ b/acceptance/bundle/resources/permissions/jobs/empty_list/out.plan.terraform.json @@ -3,6 +3,9 @@ "plan": { "resources.jobs.foo": { "action": "create" + }, + "resources.jobs.foo.permissions": { + "action": "create" } } } diff --git a/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.deploy.direct.json b/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.deploy.direct.json index e69de29bb2..f7aa2dbaa0 100644 --- a/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.deploy.direct.json +++ b/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.deploy.direct.json @@ -0,0 +1,12 @@ +{ + "method": "PUT", + "path": "/api/2.0/permissions/jobs/[NUMID]", + "body": { + "access_control_list": [ + { + "permission_level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } +} diff --git a/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.deploy.terraform.json b/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.deploy.terraform.json index e69de29bb2..f7aa2dbaa0 100644 --- a/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.deploy.terraform.json +++ b/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.deploy.terraform.json @@ -0,0 +1,12 @@ +{ + "method": "PUT", + "path": "/api/2.0/permissions/jobs/[NUMID]", + "body": { + "access_control_list": [ + { + "permission_level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } +} diff --git a/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.destroy.terraform.json b/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.destroy.terraform.json index e69de29bb2..f7aa2dbaa0 100644 --- a/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.destroy.terraform.json +++ b/acceptance/bundle/resources/permissions/jobs/empty_list/out.requests.destroy.terraform.json @@ -0,0 +1,12 @@ +{ + "method": "PUT", + "path": "/api/2.0/permissions/jobs/[NUMID]", + "body": { + "access_control_list": [ + { + "permission_level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } +} diff --git a/acceptance/bundle/resources/permissions/jobs/empty_list/output.txt b/acceptance/bundle/resources/permissions/jobs/empty_list/output.txt index ec71aa04be..1e0de88140 100644 --- a/acceptance/bundle/resources/permissions/jobs/empty_list/output.txt +++ b/acceptance/bundle/resources/permissions/jobs/empty_list/output.txt @@ -1,6 +1,11 @@ >>> [CLI] bundle validate -o json -null +[ + { + "level": "IS_OWNER", + "user_name": "[USERNAME]" + } +] >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... diff --git a/acceptance/bundle/resources/permissions/jobs/update/out.plan_set_empty.direct.json b/acceptance/bundle/resources/permissions/jobs/update/out.plan_set_empty.direct.json index 57ee99c53f..d19fda4235 100644 --- a/acceptance/bundle/resources/permissions/jobs/update/out.plan_set_empty.direct.json +++ b/acceptance/bundle/resources/permissions/jobs/update/out.plan_set_empty.direct.json @@ -78,7 +78,18 @@ "label": "${resources.jobs.job_with_permissions.id}" } ], - "action": "delete", + "action": "update", + "new_state": { + "value": { + "object_id": "/jobs/[JOB_WITH_PERMISSIONS_ID]", + "__embed__": [ + { + "level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } + }, "remote_state": { "object_id": "/jobs/[JOB_WITH_PERMISSIONS_ID]", "__embed__": [ @@ -95,6 +106,30 @@ "user_name": "[USERNAME]" } ] + }, + "changes": { + "[group_name='data-team']": { + "action": "update", + "old": { + "level": "CAN_MANAGE", + "group_name": "data-team" + }, + "remote": { + "level": "CAN_MANAGE", + "group_name": "data-team" + } + }, + "[user_name='viewer@example.com']": { + "action": "update", + "old": { + "level": "CAN_VIEW", + "user_name": "viewer@example.com" + }, + "remote": { + "level": "CAN_VIEW", + "user_name": "viewer@example.com" + } + } } } } diff --git a/acceptance/bundle/resources/permissions/jobs/update/out.plan_set_empty.terraform.json b/acceptance/bundle/resources/permissions/jobs/update/out.plan_set_empty.terraform.json index dea726555e..cc4ceee277 100644 --- a/acceptance/bundle/resources/permissions/jobs/update/out.plan_set_empty.terraform.json +++ b/acceptance/bundle/resources/permissions/jobs/update/out.plan_set_empty.terraform.json @@ -5,7 +5,7 @@ "action": "skip" }, "resources.jobs.job_with_permissions.permissions": { - "action": "delete" + "action": "update" } } } diff --git a/acceptance/bundle/resources/permissions/jobs/update/out.requests_destroy.terraform.json b/acceptance/bundle/resources/permissions/jobs/update/out.requests_destroy.terraform.json index 73495bdc38..f66a05564c 100644 --- a/acceptance/bundle/resources/permissions/jobs/update/out.requests_destroy.terraform.json +++ b/acceptance/bundle/resources/permissions/jobs/update/out.requests_destroy.terraform.json @@ -1,3 +1,15 @@ +{ + "method": "PUT", + "path": "/api/2.0/permissions/jobs/[JOB_WITH_PERMISSIONS_ID]", + "body": { + "access_control_list": [ + { + "permission_level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } +} { "method": "POST", "path": "/api/2.2/jobs/delete", diff --git a/acceptance/bundle/resources/permissions/jobs/update/out.requests_set_empty.direct.json b/acceptance/bundle/resources/permissions/jobs/update/out.requests_set_empty.direct.json index e69de29bb2..95d354f99a 100644 --- a/acceptance/bundle/resources/permissions/jobs/update/out.requests_set_empty.direct.json +++ b/acceptance/bundle/resources/permissions/jobs/update/out.requests_set_empty.direct.json @@ -0,0 +1,12 @@ +{ + "method": "PUT", + "path": "/api/2.0/permissions/jobs/[JOB_WITH_PERMISSIONS_ID]", + "body": { + "access_control_list": [ + { + "permission_level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } +} diff --git a/acceptance/bundle/resources/permissions/output.txt b/acceptance/bundle/resources/permissions/output.txt index 85a16ad6a3..7090203247 100644 --- a/acceptance/bundle/resources/permissions/output.txt +++ b/acceptance/bundle/resources/permissions/output.txt @@ -180,7 +180,24 @@ DIFF jobs/delete_one/out.requests_destroy.direct.json "body": { "job_id": "[JOB_WITH_PERMISSIONS_ID]" EXACT jobs/empty_list/out.requests.deploy.direct.json -EXACT jobs/empty_list/out.requests.destroy.direct.json +DIFF jobs/empty_list/out.requests.destroy.direct.json +--- jobs/empty_list/out.requests.destroy.direct.json ++++ jobs/empty_list/out.requests.destroy.terraform.json +@@ -1 +1,14 @@ +-[]+[ ++ { ++ "body": { ++ "access_control_list": [ ++ { ++ "permission_level": "IS_OWNER", ++ "user_name": "[USERNAME]" ++ } ++ ] ++ }, ++ "method": "PUT", ++ "path": "/api/2.0/permissions/jobs/[NUMID]" ++ } ++] MATCH jobs/other_can_manage/out.requests.deploy.direct.json DIFF jobs/other_can_manage/out.requests.destroy.direct.json --- jobs/other_can_manage/out.requests.destroy.direct.json @@ -257,12 +274,11 @@ DIFF jobs/update/out.requests_delete_all.direct.json + "path": "/api/2.0/permissions/jobs/[JOB_WITH_PERMISSIONS_ID]" + } +] -EXACT jobs/update/out.requests_destroy.direct.json -DIFF jobs/update/out.requests_set_empty.direct.json ---- jobs/update/out.requests_set_empty.direct.json -+++ jobs/update/out.requests_set_empty.terraform.json -@@ -1 +1,14 @@ --[]+[ +DIFF jobs/update/out.requests_destroy.direct.json +--- jobs/update/out.requests_destroy.direct.json ++++ jobs/update/out.requests_destroy.terraform.json +@@ -1,4 +1,16 @@ + [ + { + "body": { + "access_control_list": [ @@ -274,8 +290,11 @@ DIFF jobs/update/out.requests_set_empty.direct.json + }, + "method": "PUT", + "path": "/api/2.0/permissions/jobs/[JOB_WITH_PERMISSIONS_ID]" -+ } -+] ++ }, + { + "body": { + "job_id": "[JOB_WITH_PERMISSIONS_ID]" +EXACT jobs/update/out.requests_set_empty.direct.json MATCH jobs/viewers/out.requests.deploy.direct.json DIFF jobs/viewers/out.requests.destroy.direct.json --- jobs/viewers/out.requests.destroy.direct.json diff --git a/acceptance/bundle/resources/permissions/pipelines/empty_list/databricks.yml b/acceptance/bundle/resources/permissions/pipelines/empty_list/databricks.yml index 6089d045ff..6db75dbfda 100644 --- a/acceptance/bundle/resources/permissions/pipelines/empty_list/databricks.yml +++ b/acceptance/bundle/resources/permissions/pipelines/empty_list/databricks.yml @@ -5,5 +5,5 @@ resources: pipelines: foo: name: foo - # this is interpreted the same as nil, but it's IMO a bug; it should do default IS_OWNER setting + # An explicit empty list is managed: FixPermissions adds the default IS_OWNER for the current user. permissions: [] diff --git a/acceptance/bundle/resources/permissions/pipelines/empty_list/out.plan.direct.json b/acceptance/bundle/resources/permissions/pipelines/empty_list/out.plan.direct.json index 9749323940..029250bd21 100644 --- a/acceptance/bundle/resources/permissions/pipelines/empty_list/out.plan.direct.json +++ b/acceptance/bundle/resources/permissions/pipelines/empty_list/out.plan.direct.json @@ -15,6 +15,29 @@ "name": "foo" } } + }, + "resources.pipelines.foo.permissions": { + "depends_on": [ + { + "node": "resources.pipelines.foo", + "label": "${resources.pipelines.foo.id}" + } + ], + "action": "create", + "new_state": { + "value": { + "object_id": "", + "__embed__": [ + { + "level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + }, + "vars": { + "object_id": "/pipelines/${resources.pipelines.foo.id}" + } + } } } } diff --git a/acceptance/bundle/resources/permissions/pipelines/empty_list/out.plan.terraform.json b/acceptance/bundle/resources/permissions/pipelines/empty_list/out.plan.terraform.json index 4305cb583e..58cb00a270 100644 --- a/acceptance/bundle/resources/permissions/pipelines/empty_list/out.plan.terraform.json +++ b/acceptance/bundle/resources/permissions/pipelines/empty_list/out.plan.terraform.json @@ -3,6 +3,9 @@ "plan": { "resources.pipelines.foo": { "action": "create" + }, + "resources.pipelines.foo.permissions": { + "action": "create" } } } diff --git a/acceptance/bundle/resources/permissions/pipelines/empty_list/out.requests.deploy.direct.json b/acceptance/bundle/resources/permissions/pipelines/empty_list/out.requests.deploy.direct.json index e69de29bb2..578163835c 100644 --- a/acceptance/bundle/resources/permissions/pipelines/empty_list/out.requests.deploy.direct.json +++ b/acceptance/bundle/resources/permissions/pipelines/empty_list/out.requests.deploy.direct.json @@ -0,0 +1,12 @@ +{ + "method": "PUT", + "path": "/api/2.0/permissions/pipelines/[UUID]", + "body": { + "access_control_list": [ + { + "permission_level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } +} diff --git a/acceptance/bundle/resources/permissions/pipelines/empty_list/out.requests.deploy.terraform.json b/acceptance/bundle/resources/permissions/pipelines/empty_list/out.requests.deploy.terraform.json index e69de29bb2..578163835c 100644 --- a/acceptance/bundle/resources/permissions/pipelines/empty_list/out.requests.deploy.terraform.json +++ b/acceptance/bundle/resources/permissions/pipelines/empty_list/out.requests.deploy.terraform.json @@ -0,0 +1,12 @@ +{ + "method": "PUT", + "path": "/api/2.0/permissions/pipelines/[UUID]", + "body": { + "access_control_list": [ + { + "permission_level": "IS_OWNER", + "user_name": "[USERNAME]" + } + ] + } +} diff --git a/acceptance/bundle/resources/permissions/pipelines/empty_list/output.txt b/acceptance/bundle/resources/permissions/pipelines/empty_list/output.txt index 2f3ce99457..89f7921475 100644 --- a/acceptance/bundle/resources/permissions/pipelines/empty_list/output.txt +++ b/acceptance/bundle/resources/permissions/pipelines/empty_list/output.txt @@ -1,6 +1,11 @@ >>> [CLI] bundle validate -o json -null +[ + { + "level": "IS_OWNER", + "user_name": "[USERNAME]" + } +] >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go index f2a6d85709..794035c801 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions.go @@ -122,6 +122,7 @@ func (m *bundlePermissions) Apply(ctx context.Context, b *bundle.Bundle) diag.Di v, err = dyn.MapByPattern(v, pattern, func(p dyn.Path, v dyn.Value) (dyn.Value, error) { var permissions []resources.Permission pv, err := dyn.Get(v, "permissions") + present := err == nil // If the permissions field is not found, we set to an empty array if err != nil { pv = dyn.V([]dyn.Value{}) @@ -140,7 +141,11 @@ func (m *bundlePermissions) Apply(ctx context.Context, b *bundle.Bundle) diag.Di levelsMap[key], )...) - if len(permissions) == 0 { + // Collapse to nil only when the block was absent, leaving the + // resource unmanaged. An explicit `permissions: []` must survive as + // an empty sequence so FixPermissions adds the default IS_OWNER, + // matching both a non-empty list and Terraform. + if len(permissions) == 0 && !present { permissions = nil } From b46638630e7bfd688a2244a0ea1973031be3e9e8 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 24 Jul 2026 17:43:56 +0200 Subject: [PATCH 2/3] acc: add grants empty-list test mirroring the permissions one Grants have no default owner (no FixPermissions analog), so grants: [] grants nothing rather than injecting a principal, and validate shows [] vs null for absent. The test also records a direct-vs-terraform divergence on create: direct emits a no-op PATCH with an empty body, terraform emits no request. Co-authored-by: Isaac --- .../schemas/empty_list/databricks.yml.tmpl | 12 +++++++ .../schemas/empty_list/out.plan.direct.txt | 33 +++++++++++++++++++ .../schemas/empty_list/out.plan.terraform.txt | 8 +++++ .../out.requests.deploy.direct.json | 5 +++ .../out.requests.deploy.terraform.json | 0 .../grants/schemas/empty_list/out.test.toml | 4 +++ .../grants/schemas/empty_list/output.txt | 21 ++++++++++++ .../grants/schemas/empty_list/script | 15 +++++++++ .../grants/schemas/empty_list/test.toml | 1 + 9 files changed, 99 insertions(+) create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.plan.direct.txt create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.plan.terraform.txt create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.direct.json create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.terraform.json create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.test.toml create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/output.txt create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/script create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/test.toml diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/databricks.yml.tmpl b/acceptance/bundle/resources/grants/schemas/empty_list/databricks.yml.tmpl new file mode 100644 index 0000000000..ba68e8b85f --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/databricks.yml.tmpl @@ -0,0 +1,12 @@ +bundle: + name: schema-grants-empty-list-$UNIQUE_NAME + +resources: + schemas: + grants_schema: + name: schema_grants_empty_list_$UNIQUE_NAME + catalog_name: main + # Unlike permissions, grants have no default owner: an empty list grants + # nothing. Note the engines diverge on create: direct emits a no-op PATCH + # with an empty body, terraform emits no request at all. + grants: [] diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.direct.txt b/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.direct.txt new file mode 100644 index 0000000000..4f432da715 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.direct.txt @@ -0,0 +1,33 @@ +{ + "plan_version": 2, + "cli_version": "[CLI_VERSION]", + "plan": { + "resources.schemas.grants_schema": { + "action": "create", + "new_state": { + "value": { + "catalog_name": "main", + "name": "schema_grants_empty_list_[UNIQUE_NAME]" + } + } + }, + "resources.schemas.grants_schema.grants": { + "depends_on": [ + { + "node": "resources.schemas.grants_schema", + "label": "${resources.schemas.grants_schema.id}" + } + ], + "action": "create", + "new_state": { + "value": { + "securable_type": "schema", + "full_name": "" + }, + "vars": { + "full_name": "${resources.schemas.grants_schema.id}" + } + } + } + } +} diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.terraform.txt b/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.terraform.txt new file mode 100644 index 0000000000..f0c5799480 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.terraform.txt @@ -0,0 +1,8 @@ +{ + "cli_version": "[CLI_VERSION]", + "plan": { + "resources.schemas.grants_schema": { + "action": "create" + } + } +} diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.direct.json b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.direct.json new file mode 100644 index 0000000000..f2fd13ab2d --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.direct.json @@ -0,0 +1,5 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_grants_empty_list_[UNIQUE_NAME]", + "body": {} +} diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.terraform.json b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.terraform.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.test.toml b/acceptance/bundle/resources/grants/schemas/empty_list/out.test.toml new file mode 100644 index 0000000000..e849ec85ac --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.test.toml @@ -0,0 +1,4 @@ +Local = true +Cloud = true +RequiresUnityCatalog = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/output.txt b/acceptance/bundle/resources/grants/schemas/empty_list/output.txt new file mode 100644 index 0000000000..14a0763d9c --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/output.txt @@ -0,0 +1,21 @@ + +>>> [CLI] bundle validate -o json +[] + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/schema-grants-empty-list-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> errcode [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-empty-list-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/script b/acceptance/bundle/resources/grants/schemas/empty_list/script new file mode 100644 index 0000000000..251df2a4af --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/script @@ -0,0 +1,15 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace errcode $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +trace $CLI bundle validate -o json | jq .resources.schemas.grants_schema.grants + +errcode $CLI bundle plan -o json &> out.plan.$DATABRICKS_BUNDLE_ENGINE.txt +rm -f out.requests.txt + +trace $CLI bundle deploy +print_requests.py //permissions > out.requests.deploy.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/test.toml b/acceptance/bundle/resources/grants/schemas/empty_list/test.toml new file mode 100644 index 0000000000..ab196c758c --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/test.toml @@ -0,0 +1 @@ +Timeout = "5m" From 652b793b36305cc5ee61b43e984ef3553effac14 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 27 Jul 2026 17:46:43 +0200 Subject: [PATCH 3/3] direct: skip empty grants node on create to avoid no-op PATCH When a resource was created with grants: [] and no prior state, the direct engine created a grants node and fired a PATCH with no changes, while terraform emitted nothing. Skip the node in that case. The node is still kept when it exists in state, so removing all grants with grants: [] still plans an update and emits the removal PATCH. Co-authored-by: Isaac --- .nextchanges/bundles/grants-empty-list-noop.md | 1 + .../empty_list/databricks_populated.yml.tmpl | 12 ++++++++++++ .../schemas/empty_list/out.plan.direct.txt | 18 ------------------ .../empty_list/out.requests.deploy.direct.json | 5 ----- .../empty_list/out.requests.grant.direct.json | 17 +++++++++++++++++ .../out.requests.grant.terraform.json | 14 ++++++++++++++ .../empty_list/out.requests.remove.direct.json | 14 ++++++++++++++ .../out.requests.remove.terraform.json | 14 ++++++++++++++ .../grants/schemas/empty_list/output.txt | 14 ++++++++++++++ .../resources/grants/schemas/empty_list/script | 13 +++++++++++++ bundle/direct/bundle_plan.go | 14 ++++++++++++++ 11 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 .nextchanges/bundles/grants-empty-list-noop.md create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/databricks_populated.yml.tmpl create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.requests.grant.direct.json create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.requests.grant.terraform.json create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.requests.remove.direct.json create mode 100644 acceptance/bundle/resources/grants/schemas/empty_list/out.requests.remove.terraform.json diff --git a/.nextchanges/bundles/grants-empty-list-noop.md b/.nextchanges/bundles/grants-empty-list-noop.md new file mode 100644 index 0000000000..3ba39fd6ca --- /dev/null +++ b/.nextchanges/bundles/grants-empty-list-noop.md @@ -0,0 +1 @@ +The direct deployment engine no longer emits a no-op grants PATCH when a resource is created with an empty `grants: []` and no prior state, matching Terraform. Removing previously-granted principals by setting `grants: []` still works. diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/databricks_populated.yml.tmpl b/acceptance/bundle/resources/grants/schemas/empty_list/databricks_populated.yml.tmpl new file mode 100644 index 0000000000..111c501c05 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/databricks_populated.yml.tmpl @@ -0,0 +1,12 @@ +bundle: + name: schema-grants-empty-list-$UNIQUE_NAME + +resources: + schemas: + grants_schema: + name: schema_grants_empty_list_$UNIQUE_NAME + catalog_name: main + grants: + - principal: $CURRENT_USER_NAME + privileges: + - CREATE_TABLE diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.direct.txt b/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.direct.txt index 4f432da715..aea7bf49a7 100644 --- a/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.direct.txt +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.plan.direct.txt @@ -10,24 +10,6 @@ "name": "schema_grants_empty_list_[UNIQUE_NAME]" } } - }, - "resources.schemas.grants_schema.grants": { - "depends_on": [ - { - "node": "resources.schemas.grants_schema", - "label": "${resources.schemas.grants_schema.id}" - } - ], - "action": "create", - "new_state": { - "value": { - "securable_type": "schema", - "full_name": "" - }, - "vars": { - "full_name": "${resources.schemas.grants_schema.id}" - } - } } } } diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.direct.json b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.direct.json index f2fd13ab2d..e69de29bb2 100644 --- a/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.direct.json +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.deploy.direct.json @@ -1,5 +0,0 @@ -{ - "method": "PATCH", - "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_grants_empty_list_[UNIQUE_NAME]", - "body": {} -} diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.grant.direct.json b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.grant.direct.json new file mode 100644 index 0000000000..e493713058 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.grant.direct.json @@ -0,0 +1,17 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_grants_empty_list_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "add": [ + "CREATE_TABLE" + ], + "principal": "[USERNAME]", + "remove": [ + "ALL_PRIVILEGES" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.grant.terraform.json b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.grant.terraform.json new file mode 100644 index 0000000000..fa924388e4 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.grant.terraform.json @@ -0,0 +1,14 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_grants_empty_list_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "add": [ + "CREATE_TABLE" + ], + "principal": "[USERNAME]" + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.remove.direct.json b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.remove.direct.json new file mode 100644 index 0000000000..ff4e782577 --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.remove.direct.json @@ -0,0 +1,14 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_grants_empty_list_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "[USERNAME]", + "remove": [ + "ALL_PRIVILEGES" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.remove.terraform.json b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.remove.terraform.json new file mode 100644 index 0000000000..a7ee26dfcb --- /dev/null +++ b/acceptance/bundle/resources/grants/schemas/empty_list/out.requests.remove.terraform.json @@ -0,0 +1,14 @@ +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_grants_empty_list_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "principal": "[USERNAME]", + "remove": [ + "CREATE_TABLE" + ] + } + ] + } +} diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/output.txt b/acceptance/bundle/resources/grants/schemas/empty_list/output.txt index 14a0763d9c..e9663bcd89 100644 --- a/acceptance/bundle/resources/grants/schemas/empty_list/output.txt +++ b/acceptance/bundle/resources/grants/schemas/empty_list/output.txt @@ -8,6 +8,20 @@ Deploying resources... Updating deployment state... Deployment complete! +=== Grant a principal, then remove all grants with grants: [] + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/schema-grants-empty-list-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/schema-grants-empty-list-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + >>> errcode [CLI] bundle destroy --auto-approve The following resources will be deleted: delete resources.schemas.grants_schema diff --git a/acceptance/bundle/resources/grants/schemas/empty_list/script b/acceptance/bundle/resources/grants/schemas/empty_list/script index 251df2a4af..97c2f01df8 100644 --- a/acceptance/bundle/resources/grants/schemas/empty_list/script +++ b/acceptance/bundle/resources/grants/schemas/empty_list/script @@ -11,5 +11,18 @@ trace $CLI bundle validate -o json | jq .resources.schemas.grants_schema.grants errcode $CLI bundle plan -o json &> out.plan.$DATABRICKS_BUNDLE_ENGINE.txt rm -f out.requests.txt +# Create with an empty list: nothing to grant and no prior state, so the direct +# engine emits no request (matching terraform), not a no-op PATCH. trace $CLI bundle deploy print_requests.py //permissions > out.requests.deploy.$DATABRICKS_BUNDLE_ENGINE.json + +title "Grant a principal, then remove all grants with grants: []\n" +envsubst < databricks_populated.yml.tmpl > databricks.yml +trace $CLI bundle deploy +print_requests.py //permissions > out.requests.grant.$DATABRICKS_BUNDLE_ENGINE.json + +# Back to an empty list, but now the node is in state: the removal must still be +# planned and the PATCH must remove the previously granted privileges. +envsubst < databricks.yml.tmpl > databricks.yml +trace $CLI bundle deploy +print_requests.py //permissions > out.requests.remove.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 139012423e..a55af3955d 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -909,6 +909,20 @@ func (b *DeploymentBundle) makePlan(ctx context.Context, configRoot *config.Root return v, fmt.Errorf("unsupported resource type: %s", resourceType) } + // An empty grants list with no prior state has nothing to + // apply: creating the node would emit a no-op PATCH with no + // changes (terraform emits nothing). Skip it. When the node is + // already in state we must keep it so the removal is planned; + // the grant-removal PATCH is built from remote state, not config. + // This also covers permissions nodes, but an empty permissions + // list is populated with a default owner by FixPermissions + // before planning, so in practice only grants reach here empty. + if seq, ok := v.AsSequence(); ok && len(seq) == 0 { + if _, inState := db.State[s]; !inState { + return dyn.InvalidValue, nil + } + } + nodes = append(nodes, s) return dyn.InvalidValue, nil },