diff --git a/bundle/config/validate/folder_permissions.go b/bundle/config/validate/folder_permissions.go index c4355abaf86..bd8a416f609 100644 --- a/bundle/config/validate/folder_permissions.go +++ b/bundle/config/validate/folder_permissions.go @@ -2,17 +2,12 @@ package validate import ( "context" - "fmt" - "path" - "strconv" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/libraries" "github.com/databricks/cli/bundle/paths" "github.com/databricks/cli/bundle/permissions" "github.com/databricks/cli/libs/diag" - "github.com/databricks/databricks-sdk-go/apierr" - "github.com/databricks/databricks-sdk-go/service/workspace" "golang.org/x/sync/errgroup" ) @@ -54,44 +49,12 @@ func checkFolderPermission(ctx context.Context, b *bundle.Bundle, folderPath str } w := b.WorkspaceClient(ctx).Workspace - obj, err := getClosestExistingObject(ctx, w, folderPath) + acl, err := permissions.ResolveFolderACL(ctx, w, folderPath) if err != nil { return diag.FromErr(err) } - objPermissions, err := w.GetPermissions(ctx, workspace.GetWorkspaceObjectPermissionsRequest{ - WorkspaceObjectId: strconv.FormatInt(obj.ObjectId, 10), - WorkspaceObjectType: "directories", - }) - if err != nil { - return diag.FromErr(err) - } - - p := permissions.ObjectAclToResourcePermissions(folderPath, objPermissions.AccessControlList) - return p.Compare(b.Config.Permissions) -} - -func getClosestExistingObject(ctx context.Context, w workspace.WorkspaceInterface, folderPath string) (*workspace.ObjectInfo, error) { - for { - obj, err := w.GetStatusByPath(ctx, folderPath) - if err == nil { - return obj, nil - } - - if !apierr.IsMissing(err) { - return nil, err - } - - parent := path.Dir(folderPath) - // If the parent is the same as the current folder, then we have reached the root - if folderPath == parent { - break - } - - folderPath = parent - } - - return nil, fmt.Errorf("folder %s and its parent folders do not exist", folderPath) + return acl.Permissions.Compare(b.Config.Permissions) } // Name implements bundle.ReadOnlyMutator. diff --git a/bundle/permissions/folder_acl.go b/bundle/permissions/folder_acl.go new file mode 100644 index 00000000000..6145608a694 --- /dev/null +++ b/bundle/permissions/folder_acl.go @@ -0,0 +1,177 @@ +package permissions + +import ( + "context" + "errors" + "fmt" + "path" + "strconv" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/service/iam" + "github.com/databricks/databricks-sdk-go/service/workspace" +) + +// adminsGroup is the workspace admins group. Its members can write to any +// workspace folder regardless of the folder ACL, so writability checks must not +// conclude "cannot write" for an admin. +const adminsGroup = "admins" + +// FolderACL is the resolved access-control state of a workspace folder: the +// permissions that actually apply to it, resolved by walking up to the closest +// existing ancestor when the folder itself does not exist yet. +type FolderACL struct { + // RequestedPath is the folder the caller asked about. + RequestedPath string + // ResolvedPath is the existing object whose ACL was read. It equals + // RequestedPath when the folder exists, or the closest existing ancestor + // otherwise (a not-yet-created folder inherits its ancestor's ACL). + ResolvedPath string + // Permissions are the ACL entries that apply, keyed by principal. + Permissions *WorkspacePathPermissions +} + +// ResolveFolderACL reads the ACL that applies to folderPath. When folderPath does +// not exist, it walks up to the closest existing ancestor, since a folder created +// under it will inherit that ancestor's permissions. +func ResolveFolderACL(ctx context.Context, w workspace.WorkspaceInterface, folderPath string) (*FolderACL, error) { + obj, err := closestExistingObject(ctx, w, folderPath) + if err != nil { + return nil, err + } + + objPermissions, err := w.GetPermissions(ctx, workspace.GetWorkspaceObjectPermissionsRequest{ + WorkspaceObjectId: strconv.FormatInt(obj.ObjectId, 10), + WorkspaceObjectType: "directories", + }) + if err != nil { + return nil, err + } + + // Report against the requested path, not the resolved ancestor, so diagnostics + // name the folder the user configured. + return &FolderACL{ + RequestedPath: folderPath, + ResolvedPath: obj.Path, + Permissions: ObjectAclToResourcePermissions(folderPath, objPermissions.AccessControlList), + }, nil +} + +// Writability is the outcome of a folder writability check. It is three-valued +// because the check cannot always reach a definite answer: reading a folder ACL +// itself requires manage access, so the common "cannot write" case surfaces as a +// permission error on the read rather than as a readable ACL that omits the user. +type Writability int + +const ( + // WritabilityUnknown means writability could not be determined (e.g. the + // folder does not exist and has no existing ancestor, or the ACL read failed + // for a reason other than permission denied). Callers should not warn. + WritabilityUnknown Writability = iota + // WritabilityWritable means the user has write access (directly, via a group, + // or as an admin), or it cannot be ruled out. + WritabilityWritable + // WritabilityNotWritable means the user definitely lacks write access: the + // permissions API denied the ACL read (which requires manage access), or the + // ACL was readable but grants the user no write-capable level. + WritabilityNotWritable +) + +// CheckWritable reports whether user can write to folderPath, resolving the ACL +// (walking up to the closest existing ancestor for a not-yet-created folder) and +// interpreting a permission-denied error on the read as a definite "not writable". +// +// Reading a folder ACL requires CAN_MANAGE (managing permissions is exclusive to +// CAN_MANAGE per the workspace ACL model; the permissions GET returns 403 +// "does not have Manage permissions" otherwise), so a 403 means the user cannot +// manage, and therefore cannot write to, the folder. +// See https://docs.databricks.com/aws/en/security/auth/access-control/ +func CheckWritable(ctx context.Context, w workspace.WorkspaceInterface, folderPath string, user *iam.User) Writability { + acl, err := ResolveFolderACL(ctx, w, folderPath) + if err != nil { + if errors.Is(err, apierr.ErrPermissionDenied) { + return WritabilityNotWritable + } + return WritabilityUnknown + } + + if acl.CanWrite(user) { + return WritabilityWritable + } + return WritabilityNotWritable +} + +// CanWrite reports whether user has write access (CAN_EDIT or higher) to the +// folder, either directly or through one of their groups. +// +// It only inspects a readable ACL; prefer CheckWritable, which also interprets a +// permission-denied error on the ACL read. It is deliberately conservative: +// it assumes write access when the user is a workspace admin (admins bypass the +// folder ACL) or when their group membership is not known here, because +// concluding "cannot write" in those cases would be a false alarm. +func (f *FolderACL) CanWrite(user *iam.User) bool { + groups := userGroupNames(user) + if groups[adminsGroup] { + return true + } + + for _, p := range f.Permissions.Permissions { + if !isWriteLevel(string(p.Level)) { + continue + } + switch { + case p.UserName != "" && p.UserName == user.UserName: + return true + case p.ServicePrincipalName != "" && p.ServicePrincipalName == user.UserName: + return true + case p.GroupName != "" && groups[p.GroupName]: + return true + } + } + + return false +} + +// isWriteLevel reports whether an ACL level grants write access to a folder, +// which is what deploying a bundle requires. CAN_EDIT is the lowest such level. +func isWriteLevel(level string) bool { + return resources.GetLevelScore(level) >= resources.GetLevelScore(resources.CAN_EDIT) +} + +// userGroupNames returns the set of group display names the user belongs to. +func userGroupNames(user *iam.User) map[string]bool { + groups := make(map[string]bool, len(user.Groups)) + for _, g := range user.Groups { + if g.Display != "" { + groups[g.Display] = true + } + } + return groups +} + +// closestExistingObject returns the object at folderPath, or the closest existing +// ancestor when folderPath does not exist. A not-yet-created folder inherits the +// permissions of its closest existing ancestor. +func closestExistingObject(ctx context.Context, w workspace.WorkspaceInterface, folderPath string) (*workspace.ObjectInfo, error) { + for { + obj, err := w.GetStatusByPath(ctx, folderPath) + if err == nil { + return obj, nil + } + + if !apierr.IsMissing(err) { + return nil, err + } + + parent := path.Dir(folderPath) + // If the parent is the same as the current folder, then we have reached the root + if folderPath == parent { + break + } + + folderPath = parent + } + + return nil, fmt.Errorf("folder %s and its parent folders do not exist", folderPath) +} diff --git a/bundle/permissions/folder_acl_test.go b/bundle/permissions/folder_acl_test.go new file mode 100644 index 00000000000..45de01063ab --- /dev/null +++ b/bundle/permissions/folder_acl_test.go @@ -0,0 +1,249 @@ +package permissions + +import ( + "testing" + + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/experimental/mocks" + "github.com/databricks/databricks-sdk-go/service/iam" + "github.com/databricks/databricks-sdk-go/service/workspace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func user(name string, groups ...string) *iam.User { + u := &iam.User{UserName: name} + for _, g := range groups { + u.Groups = append(u.Groups, iam.ComplexValue{Display: g}) + } + return u +} + +func aclFor(t *testing.T, acl []workspace.WorkspaceObjectAccessControlResponse) *FolderACL { + t.Helper() + return &FolderACL{ + RequestedPath: "/Workspace/f", + ResolvedPath: "/Workspace/f", + Permissions: ObjectAclToResourcePermissions("/Workspace/f", acl), + } +} + +func TestFolderACLCanWrite(t *testing.T) { + editUser := []workspace.WorkspaceObjectAccessControlResponse{ + { + UserName: "me@example.com", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_EDIT"}, + }, + }, + } + manageGroup := []workspace.WorkspaceObjectAccessControlResponse{ + { + GroupName: "devs", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE"}, + }, + }, + } + readOnlyUser := []workspace.WorkspaceObjectAccessControlResponse{ + { + UserName: "me@example.com", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_READ"}, + }, + }, + } + sp := []workspace.WorkspaceObjectAccessControlResponse{ + { + ServicePrincipalName: "sp-uuid", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE"}, + }, + }, + } + + tests := []struct { + name string + acl []workspace.WorkspaceObjectAccessControlResponse + user *iam.User + want bool + }{ + {"direct write", editUser, user("me@example.com"), true}, + {"write via group", manageGroup, user("me@example.com", "devs"), true}, + {"read-only is not writable", readOnlyUser, user("me@example.com"), false}, + {"no matching entry", editUser, user("other@example.com"), false}, + {"service principal write", sp, user("sp-uuid"), true}, + + // Conservative cases: assume writable when it cannot be ruled out. + {"admin bypasses folder ACL", readOnlyUser, user("me@example.com", "admins"), true}, + {"write via group not in user's known groups", manageGroup, user("me@example.com"), false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, aclFor(t, tc.acl).CanWrite(tc.user)) + }) + } +} + +// These ACLs mirror the shape of real workspace folder permissions (verified +// against live traffic): a home-style folder granting the owner CAN_MANAGE +// directly plus inherited admin/service-principal entries, and a shared folder +// granting CAN_MANAGE to a group the user belongs to rather than to the user +// directly. + +// homeDirACL: owner has direct CAN_MANAGE (writable). +var homeDirACL = []workspace.WorkspaceObjectAccessControlResponse{ + { + UserName: "owner@example.com", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE"}, + }, + }, + { + ServicePrincipalName: "00000000-0000-0000-0000-000000000000", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE", Inherited: true}, + }, + }, + { + GroupName: "admins", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE", Inherited: true}, + }, + }, +} + +// sharedDirACL: CAN_MANAGE granted to the "users" group (write via group, not a +// direct grant). +var sharedDirACL = []workspace.WorkspaceObjectAccessControlResponse{ + { + GroupName: "users", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE"}, + }, + }, + { + GroupName: "admins", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE", Inherited: true}, + }, + }, +} + +func TestFolderACLCanWriteRealisticACLs(t *testing.T) { + me := user("owner@example.com", "engineering", "users") + + assert.True(t, aclFor(t, homeDirACL).CanWrite(me), "own home dir: direct CAN_MANAGE") + assert.True(t, aclFor(t, sharedDirACL).CanWrite(me), "shared dir: CAN_MANAGE via users group") + + // A different user without any of these grants cannot write. + other := user("someone.else@example.com") + assert.False(t, aclFor(t, homeDirACL).CanWrite(other)) + assert.False(t, aclFor(t, sharedDirACL).CanWrite(other)) +} + +func TestCheckWritable(t *testing.T) { + const folder = "/Workspace/f" + me := user("me@example.com") + + writableACL := &workspace.WorkspaceObjectPermissions{ + AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{ + { + UserName: "me@example.com", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE"}, + }, + }, + }, + } + readOnlyACL := &workspace.WorkspaceObjectPermissions{ + AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{ + { + UserName: "me@example.com", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_READ"}, + }, + }, + }, + } + + tests := []struct { + name string + getPerms func() (*workspace.WorkspaceObjectPermissions, error) + want Writability + }{ + { + "writable", + func() (*workspace.WorkspaceObjectPermissions, error) { return writableACL, nil }, + WritabilityWritable, + }, + { + // Reading the ACL is denied because it requires manage access, so the + // user definitely cannot write. This is the common real-world case. + "permission denied on ACL read means not writable", + func() (*workspace.WorkspaceObjectPermissions, error) { + return nil, &apierr.APIError{StatusCode: 403, ErrorCode: "PERMISSION_DENIED"} + }, + WritabilityNotWritable, + }, + { + "readable ACL without a write grant means not writable", + func() (*workspace.WorkspaceObjectPermissions, error) { return readOnlyACL, nil }, + WritabilityNotWritable, + }, + { + "other errors are unknown", + func() (*workspace.WorkspaceObjectPermissions, error) { + return nil, &apierr.APIError{StatusCode: 500} + }, + WritabilityUnknown, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := mocks.NewMockWorkspaceClient(t) + w := m.GetMockWorkspaceAPI() + w.EXPECT().GetStatusByPath(mock.Anything, folder). + Return(&workspace.ObjectInfo{ObjectId: 7, Path: folder}, nil) + w.EXPECT().GetPermissions(mock.Anything, mock.Anything).Return(tc.getPerms()) + + assert.Equal(t, tc.want, CheckWritable(t.Context(), w, folder, me)) + }) + } +} + +func TestResolveFolderACLWalksUpToClosestExistingAncestor(t *testing.T) { + m := mocks.NewMockWorkspaceClient(t) + w := m.GetMockWorkspaceAPI() + + // The requested folder does not exist yet; its parent does. + w.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/root/files"). + Return(nil, &apierr.APIError{StatusCode: 404}) + w.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/root"). + Return(&workspace.ObjectInfo{ObjectId: 42, Path: "/Workspace/root"}, nil) + w.EXPECT().GetPermissions(mock.Anything, workspace.GetWorkspaceObjectPermissionsRequest{ + WorkspaceObjectId: "42", + WorkspaceObjectType: "directories", + }).Return(&workspace.WorkspaceObjectPermissions{ + AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{ + { + UserName: "me@example.com", + AllPermissions: []workspace.WorkspaceObjectPermission{ + {PermissionLevel: "CAN_MANAGE"}, + }, + }, + }, + }, nil) + + acl, err := ResolveFolderACL(t.Context(), w, "/Workspace/root/files") + require.NoError(t, err) + + // Resolved via the ancestor, but reported against the requested path. + assert.Equal(t, "/Workspace/root/files", acl.RequestedPath) + assert.Equal(t, "/Workspace/root", acl.ResolvedPath) + assert.Equal(t, "/Workspace/root/files", acl.Permissions.Path) + assert.True(t, acl.CanWrite(user("me@example.com"))) +}