Skip to content

Commit eaea308

Browse files
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]>
1 parent 89ee7d8 commit eaea308

File tree

10 files changed

+586
-8
lines changed

10 files changed

+586
-8
lines changed

bundle/config/resources/permission.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package resources
22

3+
import "fmt"
4+
35
// Permission holds the permission level setting for a single principal.
46
// Multiple of these can be defined on any resource.
57
type Permission struct {
@@ -9,3 +11,19 @@ type Permission struct {
911
ServicePrincipalName string `json:"service_principal_name,omitempty"`
1012
GroupName string `json:"group_name,omitempty"`
1113
}
14+
15+
func (p Permission) String() string {
16+
if p.UserName != "" {
17+
return fmt.Sprintf("level: %s, user_name: %s", p.Level, p.UserName)
18+
}
19+
20+
if p.ServicePrincipalName != "" {
21+
return fmt.Sprintf("level: %s, service_principal_name: %s", p.Level, p.ServicePrincipalName)
22+
}
23+
24+
if p.GroupName != "" {
25+
return fmt.Sprintf("level: %s, group_name: %s", p.Level, p.GroupName)
26+
}
27+
28+
return fmt.Sprintf("level: %s", p.Level)
29+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package validate
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path"
7+
"strings"
8+
9+
"github.com/databricks/cli/bundle"
10+
"github.com/databricks/cli/bundle/libraries"
11+
"github.com/databricks/cli/bundle/permissions"
12+
"github.com/databricks/cli/libs/diag"
13+
"github.com/databricks/databricks-sdk-go/apierr"
14+
"github.com/databricks/databricks-sdk-go/service/workspace"
15+
"golang.org/x/sync/errgroup"
16+
)
17+
18+
type folderPermissions struct {
19+
}
20+
21+
// Apply implements bundle.ReadOnlyMutator.
22+
func (f *folderPermissions) Apply(ctx context.Context, b bundle.ReadOnlyBundle) diag.Diagnostics {
23+
if len(b.Config().Permissions) == 0 {
24+
return nil
25+
}
26+
27+
rootPath := b.Config().Workspace.RootPath
28+
paths := []string{}
29+
if !libraries.IsVolumesPath(rootPath) && !libraries.IsWorkspaceSharedPath(rootPath) {
30+
paths = append(paths, rootPath)
31+
}
32+
33+
if !strings.HasSuffix(rootPath, "/") {
34+
rootPath += "/"
35+
}
36+
37+
for _, p := range []string{
38+
b.Config().Workspace.ArtifactPath,
39+
b.Config().Workspace.FilePath,
40+
b.Config().Workspace.StatePath,
41+
b.Config().Workspace.ResourcePath,
42+
} {
43+
if libraries.IsWorkspaceSharedPath(p) || libraries.IsVolumesPath(p) {
44+
continue
45+
}
46+
47+
if strings.HasPrefix(p, rootPath) {
48+
continue
49+
}
50+
51+
paths = append(paths, p)
52+
}
53+
54+
var diags diag.Diagnostics
55+
g, ctx := errgroup.WithContext(ctx)
56+
results := make([]diag.Diagnostics, len(paths))
57+
for i, p := range paths {
58+
g.Go(func() error {
59+
results[i] = checkFolderPermission(ctx, b, p)
60+
return nil
61+
})
62+
}
63+
64+
if err := g.Wait(); err != nil {
65+
return diag.FromErr(err)
66+
}
67+
68+
for _, r := range results {
69+
diags = diags.Extend(r)
70+
}
71+
72+
return diags
73+
}
74+
75+
func checkFolderPermission(ctx context.Context, b bundle.ReadOnlyBundle, folderPath string) diag.Diagnostics {
76+
w := b.WorkspaceClient().Workspace
77+
obj, err := getClosestExistingObject(ctx, w, folderPath)
78+
if err != nil {
79+
return diag.FromErr(err)
80+
}
81+
82+
objPermissions, err := w.GetPermissions(ctx, workspace.GetWorkspaceObjectPermissionsRequest{
83+
WorkspaceObjectId: fmt.Sprint(obj.ObjectId),
84+
WorkspaceObjectType: "directories",
85+
})
86+
if err != nil {
87+
return diag.FromErr(err)
88+
}
89+
90+
p := permissions.ObjectAclToResourcePermissions(folderPath, objPermissions.AccessControlList)
91+
return p.Compare(b.Config().Permissions)
92+
}
93+
94+
func getClosestExistingObject(ctx context.Context, w workspace.WorkspaceInterface, folderPath string) (*workspace.ObjectInfo, error) {
95+
for {
96+
obj, err := w.GetStatusByPath(ctx, folderPath)
97+
if err == nil {
98+
return obj, nil
99+
}
100+
101+
if !apierr.IsMissing(err) {
102+
return nil, err
103+
}
104+
105+
parent := path.Dir(folderPath)
106+
// If the parent is the same as the current folder, then we have reached the root
107+
if folderPath == parent {
108+
break
109+
}
110+
111+
folderPath = parent
112+
}
113+
114+
return nil, fmt.Errorf("folder %s and its parent folders do not exist", folderPath)
115+
}
116+
117+
// Name implements bundle.ReadOnlyMutator.
118+
func (f *folderPermissions) Name() string {
119+
return "validate:folder_permissions"
120+
}
121+
122+
// ValidateFolderPermissions validates that permissions for the folders in Workspace file system matches
123+
// the permissions in the top-level permissions section of the bundle.
124+
func ValidateFolderPermissions() bundle.ReadOnlyMutator {
125+
return &folderPermissions{}
126+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package validate
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/databricks/cli/bundle"
8+
"github.com/databricks/cli/bundle/config"
9+
"github.com/databricks/cli/bundle/config/resources"
10+
"github.com/databricks/cli/bundle/permissions"
11+
"github.com/databricks/cli/libs/diag"
12+
"github.com/databricks/databricks-sdk-go/apierr"
13+
"github.com/databricks/databricks-sdk-go/experimental/mocks"
14+
"github.com/databricks/databricks-sdk-go/service/workspace"
15+
"github.com/stretchr/testify/mock"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestFolderPermissionsInheritedWhenRootPathDoesNotExist(t *testing.T) {
20+
b := &bundle.Bundle{
21+
Config: config.Root{
22+
Workspace: config.Workspace{
23+
RootPath: "/Workspace/Users/[email protected]",
24+
ArtifactPath: "/Workspace/Users/[email protected]/artifacts",
25+
FilePath: "/Workspace/Users/[email protected]/files",
26+
StatePath: "/Workspace/Users/[email protected]/state",
27+
ResourcePath: "/Workspace/Users/[email protected]/resources",
28+
},
29+
Permissions: []resources.Permission{
30+
{Level: permissions.CAN_MANAGE, UserName: "[email protected]"},
31+
},
32+
},
33+
}
34+
m := mocks.NewMockWorkspaceClient(t)
35+
api := m.GetMockWorkspaceAPI()
36+
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]/artifacts").Return(nil, &apierr.APIError{
37+
StatusCode: 404,
38+
ErrorCode: "RESOURCE_DOES_NOT_EXIST",
39+
})
40+
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]").Return(nil, &apierr.APIError{
41+
StatusCode: 404,
42+
ErrorCode: "RESOURCE_DOES_NOT_EXIST",
43+
})
44+
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]").Return(nil, &apierr.APIError{
45+
StatusCode: 404,
46+
ErrorCode: "RESOURCE_DOES_NOT_EXIST",
47+
})
48+
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users").Return(nil, &apierr.APIError{
49+
StatusCode: 404,
50+
ErrorCode: "RESOURCE_DOES_NOT_EXIST",
51+
})
52+
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace").Return(&workspace.ObjectInfo{
53+
ObjectId: 1234,
54+
}, nil)
55+
56+
api.EXPECT().GetPermissions(mock.Anything, workspace.GetWorkspaceObjectPermissionsRequest{
57+
WorkspaceObjectId: "1234",
58+
WorkspaceObjectType: "directories",
59+
}).Return(&workspace.WorkspaceObjectPermissions{
60+
ObjectId: "1234",
61+
AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{
62+
{
63+
UserName: "[email protected]",
64+
AllPermissions: []workspace.WorkspaceObjectPermission{
65+
{PermissionLevel: "CAN_MANAGE"},
66+
},
67+
},
68+
},
69+
}, nil)
70+
71+
b.SetWorkpaceClient(m.WorkspaceClient)
72+
rb := bundle.ReadOnly(b)
73+
74+
diags := bundle.ApplyReadOnly(context.Background(), rb, ValidateFolderPermissions())
75+
require.Empty(t, diags)
76+
}
77+
78+
func TestValidateFolderPermissionsFailsOnMissingBundlePermission(t *testing.T) {
79+
b := &bundle.Bundle{
80+
Config: config.Root{
81+
Workspace: config.Workspace{
82+
RootPath: "/Workspace/Users/[email protected]",
83+
ArtifactPath: "/Workspace/Users/[email protected]/artifacts",
84+
FilePath: "/Workspace/Users/[email protected]/files",
85+
StatePath: "/Workspace/Users/[email protected]/state",
86+
ResourcePath: "/Workspace/Users/[email protected]/resources",
87+
},
88+
Permissions: []resources.Permission{
89+
{Level: permissions.CAN_MANAGE, UserName: "[email protected]"},
90+
},
91+
},
92+
}
93+
m := mocks.NewMockWorkspaceClient(t)
94+
api := m.GetMockWorkspaceAPI()
95+
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]").Return(&workspace.ObjectInfo{
96+
ObjectId: 1234,
97+
}, nil)
98+
99+
api.EXPECT().GetPermissions(mock.Anything, workspace.GetWorkspaceObjectPermissionsRequest{
100+
WorkspaceObjectId: "1234",
101+
WorkspaceObjectType: "directories",
102+
}).Return(&workspace.WorkspaceObjectPermissions{
103+
ObjectId: "1234",
104+
AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{
105+
{
106+
UserName: "[email protected]",
107+
AllPermissions: []workspace.WorkspaceObjectPermission{
108+
{PermissionLevel: "CAN_MANAGE"},
109+
},
110+
},
111+
{
112+
UserName: "[email protected]",
113+
AllPermissions: []workspace.WorkspaceObjectPermission{
114+
{PermissionLevel: "CAN_MANAGE"},
115+
},
116+
},
117+
},
118+
}, nil)
119+
120+
b.SetWorkpaceClient(m.WorkspaceClient)
121+
rb := bundle.ReadOnly(b)
122+
123+
diags := bundle.ApplyReadOnly(context.Background(), rb, ValidateFolderPermissions())
124+
require.Len(t, diags, 1)
125+
require.Equal(t, "untracked permissions apply to target workspace path", diags[0].Summary)
126+
require.Equal(t, diag.Warning, diags[0].Severity)
127+
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)
128+
}
129+
130+
func TestValidateFolderPermissionsFailsOnPermissionMismatch(t *testing.T) {
131+
b := &bundle.Bundle{
132+
Config: config.Root{
133+
Workspace: config.Workspace{
134+
RootPath: "/Workspace/Users/[email protected]",
135+
ArtifactPath: "/Workspace/Users/[email protected]/artifacts",
136+
FilePath: "/Workspace/Users/[email protected]/files",
137+
StatePath: "/Workspace/Users/[email protected]/state",
138+
ResourcePath: "/Workspace/Users/[email protected]/resources",
139+
},
140+
Permissions: []resources.Permission{
141+
{Level: permissions.CAN_MANAGE, UserName: "[email protected]"},
142+
},
143+
},
144+
}
145+
m := mocks.NewMockWorkspaceClient(t)
146+
api := m.GetMockWorkspaceAPI()
147+
api.EXPECT().GetStatusByPath(mock.Anything, "/Workspace/Users/[email protected]").Return(&workspace.ObjectInfo{
148+
ObjectId: 1234,
149+
}, nil)
150+
151+
api.EXPECT().GetPermissions(mock.Anything, workspace.GetWorkspaceObjectPermissionsRequest{
152+
WorkspaceObjectId: "1234",
153+
WorkspaceObjectType: "directories",
154+
}).Return(&workspace.WorkspaceObjectPermissions{
155+
ObjectId: "1234",
156+
AccessControlList: []workspace.WorkspaceObjectAccessControlResponse{
157+
{
158+
UserName: "[email protected]",
159+
AllPermissions: []workspace.WorkspaceObjectPermission{
160+
{PermissionLevel: "CAN_MANAGE"},
161+
},
162+
},
163+
},
164+
}, nil)
165+
166+
b.SetWorkpaceClient(m.WorkspaceClient)
167+
rb := bundle.ReadOnly(b)
168+
169+
diags := bundle.ApplyReadOnly(context.Background(), rb, ValidateFolderPermissions())
170+
require.Len(t, diags, 1)
171+
require.Equal(t, "untracked permissions apply to target workspace path", diags[0].Summary)
172+
require.Equal(t, diag.Warning, diags[0].Severity)
173+
}
174+
175+
func TestValidateFolderPermissionsFailsOnNoRootFolder(t *testing.T) {
176+
b := &bundle.Bundle{
177+
Config: config.Root{
178+
Workspace: config.Workspace{
179+
RootPath: "/NotExisting",
180+
ArtifactPath: "/NotExisting/artifacts",
181+
FilePath: "/NotExisting/files",
182+
StatePath: "/NotExisting/state",
183+
ResourcePath: "/NotExisting/resources",
184+
},
185+
Permissions: []resources.Permission{
186+
{Level: permissions.CAN_MANAGE, UserName: "[email protected]"},
187+
},
188+
},
189+
}
190+
m := mocks.NewMockWorkspaceClient(t)
191+
api := m.GetMockWorkspaceAPI()
192+
api.EXPECT().GetStatusByPath(mock.Anything, "/NotExisting").Return(nil, &apierr.APIError{
193+
StatusCode: 404,
194+
ErrorCode: "RESOURCE_DOES_NOT_EXIST",
195+
})
196+
api.EXPECT().GetStatusByPath(mock.Anything, "/").Return(nil, &apierr.APIError{
197+
StatusCode: 404,
198+
ErrorCode: "RESOURCE_DOES_NOT_EXIST",
199+
})
200+
201+
b.SetWorkpaceClient(m.WorkspaceClient)
202+
rb := bundle.ReadOnly(b)
203+
204+
diags := bundle.ApplyReadOnly(context.Background(), rb, ValidateFolderPermissions())
205+
require.Len(t, diags, 1)
206+
require.Equal(t, "folder / and its parent folders do not exist", diags[0].Summary)
207+
require.Equal(t, diag.Error, diags[0].Severity)
208+
}

bundle/config/validate/validate.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func (v *validate) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics
3535
FilesToSync(),
3636
ValidateSyncPatterns(),
3737
JobTaskClusterSpec(),
38+
ValidateFolderPermissions(),
3839
))
3940
}
4041

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,12 @@ func IsWorkspaceLibrary(library *compute.Library) bool {
3636

3737
return IsWorkspacePath(path)
3838
}
39+
40+
// IsVolumesPath returns true if the specified path indicates that
41+
func IsVolumesPath(path string) bool {
42+
return strings.HasPrefix(path, "/Volumes/")
43+
}
44+
45+
func IsWorkspaceSharedPath(path string) bool {
46+
return strings.HasPrefix(path, "/Workspace/Shared/")
47+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,13 @@ func TestIsWorkspaceLibrary(t *testing.T) {
3131
// Empty.
3232
assert.False(t, IsWorkspaceLibrary(&compute.Library{}))
3333
}
34+
35+
func TestIsVolumesPath(t *testing.T) {
36+
// Absolute paths with particular prefixes.
37+
assert.True(t, IsVolumesPath("/Volumes/path/to/package"))
38+
39+
// Relative paths.
40+
assert.False(t, IsVolumesPath("myfile.txt"))
41+
assert.False(t, IsVolumesPath("./myfile.txt"))
42+
assert.False(t, IsVolumesPath("../myfile.txt"))
43+
}

0 commit comments

Comments
 (0)