-
Notifications
You must be signed in to change notification settings - Fork 55
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added validator for folder permissions (#1824)
## Changes This validator checks permissions defined in top-level bundle config and permissions set in workspace for the folders bundle is deployed to. It raises the warning if the permissions defined in the workspace are not defined in bundle. This validator is executed only during `bundle validate` command. ## Tests ``` Warning: untracked permissions apply to target workspace path The following permissions apply to the workspace folder at "/Workspace/Users/[email protected]/.bundle/clusters/default" but are not configured in the bundle: - level: CAN_MANAGE, user_name: [email protected] ``` --------- Co-authored-by: Pieter Noordhuis <[email protected]>
- Loading branch information
1 parent
89ee7d8
commit eaea308
Showing
10 changed files
with
586 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
package validate | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"path" | ||
"strings" | ||
|
||
"github.com/databricks/cli/bundle" | ||
"github.com/databricks/cli/bundle/libraries" | ||
"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" | ||
) | ||
|
||
type folderPermissions struct { | ||
} | ||
|
||
// Apply implements bundle.ReadOnlyMutator. | ||
func (f *folderPermissions) Apply(ctx context.Context, b bundle.ReadOnlyBundle) diag.Diagnostics { | ||
if len(b.Config().Permissions) == 0 { | ||
return nil | ||
} | ||
|
||
rootPath := b.Config().Workspace.RootPath | ||
paths := []string{} | ||
if !libraries.IsVolumesPath(rootPath) && !libraries.IsWorkspaceSharedPath(rootPath) { | ||
paths = append(paths, rootPath) | ||
} | ||
|
||
if !strings.HasSuffix(rootPath, "/") { | ||
rootPath += "/" | ||
} | ||
|
||
for _, p := range []string{ | ||
b.Config().Workspace.ArtifactPath, | ||
b.Config().Workspace.FilePath, | ||
b.Config().Workspace.StatePath, | ||
b.Config().Workspace.ResourcePath, | ||
} { | ||
if libraries.IsWorkspaceSharedPath(p) || libraries.IsVolumesPath(p) { | ||
continue | ||
} | ||
|
||
if strings.HasPrefix(p, rootPath) { | ||
continue | ||
} | ||
|
||
paths = append(paths, p) | ||
} | ||
|
||
var diags diag.Diagnostics | ||
g, ctx := errgroup.WithContext(ctx) | ||
results := make([]diag.Diagnostics, len(paths)) | ||
for i, p := range paths { | ||
g.Go(func() error { | ||
results[i] = checkFolderPermission(ctx, b, p) | ||
return nil | ||
}) | ||
} | ||
|
||
if err := g.Wait(); err != nil { | ||
return diag.FromErr(err) | ||
} | ||
|
||
for _, r := range results { | ||
diags = diags.Extend(r) | ||
} | ||
|
||
return diags | ||
} | ||
|
||
func checkFolderPermission(ctx context.Context, b bundle.ReadOnlyBundle, folderPath string) diag.Diagnostics { | ||
w := b.WorkspaceClient().Workspace | ||
obj, err := getClosestExistingObject(ctx, w, folderPath) | ||
if err != nil { | ||
return diag.FromErr(err) | ||
} | ||
|
||
objPermissions, err := w.GetPermissions(ctx, workspace.GetWorkspaceObjectPermissionsRequest{ | ||
WorkspaceObjectId: fmt.Sprint(obj.ObjectId), | ||
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) | ||
} | ||
|
||
// Name implements bundle.ReadOnlyMutator. | ||
func (f *folderPermissions) Name() string { | ||
return "validate:folder_permissions" | ||
} | ||
|
||
// ValidateFolderPermissions validates that permissions for the folders in Workspace file system matches | ||
// the permissions in the top-level permissions section of the bundle. | ||
func ValidateFolderPermissions() bundle.ReadOnlyMutator { | ||
return &folderPermissions{} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,208 @@ | ||
package validate | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
|
||
"github.com/databricks/cli/bundle" | ||
"github.com/databricks/cli/bundle/config" | ||
"github.com/databricks/cli/bundle/config/resources" | ||
"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/experimental/mocks" | ||
"github.com/databricks/databricks-sdk-go/service/workspace" | ||
"github.com/stretchr/testify/mock" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestFolderPermissionsInheritedWhenRootPathDoesNotExist(t *testing.T) { | ||
b := &bundle.Bundle{ | ||
Config: config.Root{ | ||
Workspace: config.Workspace{ | ||
RootPath: "/Workspace/Users/[email protected]", | ||
ArtifactPath: "/Workspace/Users/[email protected]/artifacts", | ||
FilePath: "/Workspace/Users/[email protected]/files", | ||
StatePath: "/Workspace/Users/[email protected]/state", | ||
ResourcePath: "/Workspace/Users/[email protected]/resources", | ||
}, | ||
Permissions: []resources.Permission{ | ||
{Level: permissions.CAN_MANAGE, UserName: "[email protected]"}, | ||
}, | ||
}, | ||
} | ||
m := mocks.NewMockWorkspaceClient(t) | ||
api := m.GetMockWorkspaceAPI() | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]/artifacts").Return(nil, &apierr.APIError{ | ||
StatusCode: 404, | ||
ErrorCode: "RESOURCE_DOES_NOT_EXIST", | ||
}) | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]").Return(nil, &apierr.APIError{ | ||
StatusCode: 404, | ||
ErrorCode: "RESOURCE_DOES_NOT_EXIST", | ||
}) | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]").Return(nil, &apierr.APIError{ | ||
StatusCode: 404, | ||
ErrorCode: "RESOURCE_DOES_NOT_EXIST", | ||
}) | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users").Return(nil, &apierr.APIError{ | ||
StatusCode: 404, | ||
ErrorCode: "RESOURCE_DOES_NOT_EXIST", | ||
}) | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace").Return(&workspace.ObjectInfo{ | ||
ObjectId: 1234, | ||
}, nil) | ||
|
||
api.EXPECT().GetPermissions(mock.Anything, workspace.GetWorkspaceObjectPermissionsRequest{ | ||
WorkspaceObjectId: "1234", | ||
WorkspaceObjectType: "directories", | ||
}).Return(&workspace.WorkspaceObjectPermissions{ | ||
ObjectId: "1234", | ||
AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{ | ||
{ | ||
UserName: "[email protected]", | ||
AllPermissions: []workspace.WorkspaceObjectPermission{ | ||
{PermissionLevel: "CAN_MANAGE"}, | ||
}, | ||
}, | ||
}, | ||
}, nil) | ||
|
||
b.SetWorkpaceClient(m.WorkspaceClient) | ||
rb := bundle.ReadOnly(b) | ||
|
||
diags := bundle.ApplyReadOnly(context.Background(), rb, ValidateFolderPermissions()) | ||
require.Empty(t, diags) | ||
} | ||
|
||
func TestValidateFolderPermissionsFailsOnMissingBundlePermission(t *testing.T) { | ||
b := &bundle.Bundle{ | ||
Config: config.Root{ | ||
Workspace: config.Workspace{ | ||
RootPath: "/Workspace/Users/[email protected]", | ||
ArtifactPath: "/Workspace/Users/[email protected]/artifacts", | ||
FilePath: "/Workspace/Users/[email protected]/files", | ||
StatePath: "/Workspace/Users/[email protected]/state", | ||
ResourcePath: "/Workspace/Users/[email protected]/resources", | ||
}, | ||
Permissions: []resources.Permission{ | ||
{Level: permissions.CAN_MANAGE, UserName: "[email protected]"}, | ||
}, | ||
}, | ||
} | ||
m := mocks.NewMockWorkspaceClient(t) | ||
api := m.GetMockWorkspaceAPI() | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]").Return(&workspace.ObjectInfo{ | ||
ObjectId: 1234, | ||
}, nil) | ||
|
||
api.EXPECT().GetPermissions(mock.Anything, workspace.GetWorkspaceObjectPermissionsRequest{ | ||
WorkspaceObjectId: "1234", | ||
WorkspaceObjectType: "directories", | ||
}).Return(&workspace.WorkspaceObjectPermissions{ | ||
ObjectId: "1234", | ||
AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{ | ||
{ | ||
UserName: "[email protected]", | ||
AllPermissions: []workspace.WorkspaceObjectPermission{ | ||
{PermissionLevel: "CAN_MANAGE"}, | ||
}, | ||
}, | ||
{ | ||
UserName: "[email protected]", | ||
AllPermissions: []workspace.WorkspaceObjectPermission{ | ||
{PermissionLevel: "CAN_MANAGE"}, | ||
}, | ||
}, | ||
}, | ||
}, nil) | ||
|
||
b.SetWorkpaceClient(m.WorkspaceClient) | ||
rb := bundle.ReadOnly(b) | ||
|
||
diags := bundle.ApplyReadOnly(context.Background(), rb, ValidateFolderPermissions()) | ||
require.Len(t, diags, 1) | ||
require.Equal(t, "untracked permissions apply to target workspace path", diags[0].Summary) | ||
require.Equal(t, diag.Warning, diags[0].Severity) | ||
require.Equal(t, "The following permissions apply to the workspace folder at \"/Workspace/Users/[email protected]\" but are not configured in the bundle:\n- level: CAN_MANAGE, user_name: [email protected]\n", diags[0].Detail) | ||
} | ||
|
||
func TestValidateFolderPermissionsFailsOnPermissionMismatch(t *testing.T) { | ||
b := &bundle.Bundle{ | ||
Config: config.Root{ | ||
Workspace: config.Workspace{ | ||
RootPath: "/Workspace/Users/[email protected]", | ||
ArtifactPath: "/Workspace/Users/[email protected]/artifacts", | ||
FilePath: "/Workspace/Users/[email protected]/files", | ||
StatePath: "/Workspace/Users/[email protected]/state", | ||
ResourcePath: "/Workspace/Users/[email protected]/resources", | ||
}, | ||
Permissions: []resources.Permission{ | ||
{Level: permissions.CAN_MANAGE, UserName: "[email protected]"}, | ||
}, | ||
}, | ||
} | ||
m := mocks.NewMockWorkspaceClient(t) | ||
api := m.GetMockWorkspaceAPI() | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]").Return(&workspace.ObjectInfo{ | ||
ObjectId: 1234, | ||
}, nil) | ||
|
||
api.EXPECT().GetPermissions(mock.Anything, workspace.GetWorkspaceObjectPermissionsRequest{ | ||
WorkspaceObjectId: "1234", | ||
WorkspaceObjectType: "directories", | ||
}).Return(&workspace.WorkspaceObjectPermissions{ | ||
ObjectId: "1234", | ||
AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{ | ||
{ | ||
UserName: "[email protected]", | ||
AllPermissions: []workspace.WorkspaceObjectPermission{ | ||
{PermissionLevel: "CAN_MANAGE"}, | ||
}, | ||
}, | ||
}, | ||
}, nil) | ||
|
||
b.SetWorkpaceClient(m.WorkspaceClient) | ||
rb := bundle.ReadOnly(b) | ||
|
||
diags := bundle.ApplyReadOnly(context.Background(), rb, ValidateFolderPermissions()) | ||
require.Len(t, diags, 1) | ||
require.Equal(t, "untracked permissions apply to target workspace path", diags[0].Summary) | ||
require.Equal(t, diag.Warning, diags[0].Severity) | ||
} | ||
|
||
func TestValidateFolderPermissionsFailsOnNoRootFolder(t *testing.T) { | ||
b := &bundle.Bundle{ | ||
Config: config.Root{ | ||
Workspace: config.Workspace{ | ||
RootPath: "/NotExisting", | ||
ArtifactPath: "/NotExisting/artifacts", | ||
FilePath: "/NotExisting/files", | ||
StatePath: "/NotExisting/state", | ||
ResourcePath: "/NotExisting/resources", | ||
}, | ||
Permissions: []resources.Permission{ | ||
{Level: permissions.CAN_MANAGE, UserName: "[email protected]"}, | ||
}, | ||
}, | ||
} | ||
m := mocks.NewMockWorkspaceClient(t) | ||
api := m.GetMockWorkspaceAPI() | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/NotExisting").Return(nil, &apierr.APIError{ | ||
StatusCode: 404, | ||
ErrorCode: "RESOURCE_DOES_NOT_EXIST", | ||
}) | ||
api.EXPECT().GetStatusByPath(mock.Anything, "/").Return(nil, &apierr.APIError{ | ||
StatusCode: 404, | ||
ErrorCode: "RESOURCE_DOES_NOT_EXIST", | ||
}) | ||
|
||
b.SetWorkpaceClient(m.WorkspaceClient) | ||
rb := bundle.ReadOnly(b) | ||
|
||
diags := bundle.ApplyReadOnly(context.Background(), rb, ValidateFolderPermissions()) | ||
require.Len(t, diags, 1) | ||
require.Equal(t, "folder / and its parent folders do not exist", diags[0].Summary) | ||
require.Equal(t, diag.Error, diags[0].Severity) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.