Skip to content

Commit 5eb908d

Browse files
authored
Exporter: export permissions for databricks_notebook & databricks_directory (#1908)
1 parent 851863c commit 5eb908d

File tree

9 files changed

+523
-2
lines changed

9 files changed

+523
-2
lines changed

exporter/exporter_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ func TestImportingUsersGroupsSecretScopes(t *testing.T) {
460460
Resource: "/api/2.0/secrets/acls/get?principal=users&scope=a",
461461
Response: secrets.ACLItem{Permission: "READ", Principal: "users"},
462462
},
463+
emptyWorkspace,
463464
}, func(ctx context.Context, client *common.DatabricksClient) {
464465
tmpDir := fmt.Sprintf("/tmp/tf-%s", qa.RandomName())
465466
defer os.RemoveAll(tmpDir)
@@ -527,6 +528,7 @@ func TestImportingNoResourcesError(t *testing.T) {
527528
Scopes: []secrets.SecretScope{},
528529
},
529530
},
531+
emptyWorkspace,
530532
}, func(ctx context.Context, client *common.DatabricksClient) {
531533
tmpDir := fmt.Sprintf("/tmp/tf-%s", qa.RandomName())
532534
defer os.RemoveAll(tmpDir)
@@ -1588,6 +1590,11 @@ func TestImportingDLTPipelines(t *testing.T) {
15881590
Resource: "/api/2.0/permissions/pipelines/123",
15891591
Response: getJSONObject("test-data/get-pipeline-permissions.json"),
15901592
},
1593+
{
1594+
Method: "GET",
1595+
Resource: "/api/2.0/permissions/notebooks/123",
1596+
Response: getJSONObject("test-data/get-notebook-permissions.json"),
1597+
},
15911598
{
15921599
Method: "GET",
15931600
Resource: "/api/2.0/workspace/get-status?path=%2FUsers%2Fuser%40domain.com%2FTest%20DLT",

exporter/importables.go

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -749,8 +749,8 @@ var resourcesMap map[string]importable = map[string]importable{
749749
{Path: "registered_model_id", Resource: "databricks_mlflow_model"},
750750
{Path: "experiment_id", Resource: "databricks_mlflow_experiment"},
751751
{Path: "repo_id", Resource: "databricks_repo"},
752-
{Path: "directory_path", Resource: "databricks_directory"},
753-
{Path: "notebook_path", Resource: "databricks_notebook"},
752+
{Path: "directory_id", Resource: "databricks_directory", Match: "object_id"},
753+
{Path: "notebook_id", Resource: "databricks_notebook", Match: "object_id"},
754754
{Path: "access_control.user_name", Resource: "databricks_user", Match: "user_name"},
755755
{Path: "access_control.group_name", Resource: "databricks_group", Match: "display_name"},
756756
{Path: "access_control.service_principal_name", Resource: "databricks_service_principal", Match: "application_id"},
@@ -1163,6 +1163,28 @@ var resourcesMap map[string]importable = map[string]importable{
11631163
name := r.ID[1:] + ext[language] // todo: replace non-alphanum+/ with _
11641164
content, _ := base64.StdEncoding.DecodeString(contentB64)
11651165
fileName, err := ic.createFileIn("notebooks", name, []byte(content))
1166+
splits := strings.Split(r.Name, "_")
1167+
notebookId := splits[len(splits)-1]
1168+
directorySplits := strings.Split(r.ID, "/")
1169+
directorySplits = directorySplits[:len(directorySplits)-1]
1170+
directoryPath := strings.Join(directorySplits, "/")
1171+
1172+
if ic.meAdmin {
1173+
ic.Emit(&resource{
1174+
Resource: "databricks_permissions",
1175+
ID: fmt.Sprintf("/notebooks/%s", notebookId),
1176+
Name: "notebook_" + ic.Importables["databricks_notebook"].Name(ic, r.Data),
1177+
})
1178+
}
1179+
1180+
if ic.meAdmin {
1181+
ic.Emit(&resource{
1182+
Resource: "databricks_directory",
1183+
Attribute: "path",
1184+
Value: directoryPath,
1185+
})
1186+
}
1187+
11661188
if err != nil {
11671189
return err
11681190
}
@@ -1515,4 +1537,64 @@ var resourcesMap map[string]importable = map[string]importable{
15151537
{Path: "library.whl", Resource: "databricks_dbfs_file", Match: "dbfs_path"},
15161538
},
15171539
},
1540+
"databricks_directory": {
1541+
Service: "notebooks",
1542+
Name: func(ic *importContext, d *schema.ResourceData) string {
1543+
name := d.Get("path").(string)
1544+
if name == "" {
1545+
return d.Id()
1546+
} else {
1547+
name = nameNormalizationRegex.ReplaceAllString(name[1:], "_") + "_" +
1548+
strconv.FormatInt(int64(d.Get("object_id").(int)), 10)
1549+
}
1550+
return name
1551+
},
1552+
List: func(ic *importContext) error {
1553+
notebooksAPI := workspace.NewNotebooksAPI(ic.Context, ic.Client)
1554+
directoryList, err := notebooksAPI.ListDirectories("/", true)
1555+
if err != nil {
1556+
return err
1557+
}
1558+
for offset, directory := range directoryList {
1559+
if strings.HasPrefix(directory.Path, "/Repos") {
1560+
continue
1561+
}
1562+
ic.Emit(&resource{
1563+
Resource: "databricks_directory",
1564+
ID: directory.Path,
1565+
})
1566+
if offset%50 == 0 {
1567+
log.Printf("[INFO] Scanned %d of %d directories",
1568+
offset+1, len(directoryList))
1569+
}
1570+
}
1571+
return nil
1572+
},
1573+
Import: func(ic *importContext, r *resource) error {
1574+
1575+
ic.emitUserOrServicePrincipalForPath(r.ID, "/Users")
1576+
splits := strings.Split(r.Name, "_")
1577+
directoryId := splits[len(splits)-1]
1578+
1579+
if ic.meAdmin {
1580+
ic.Emit(&resource{
1581+
Resource: "databricks_permissions",
1582+
ID: fmt.Sprintf("/directories/%s", directoryId),
1583+
Name: "directory_" + ic.Importables["databricks_directory"].Name(ic, r.Data),
1584+
})
1585+
}
1586+
1587+
if r.ID == "/Shared" || r.ID == "/Users" || ic.IsUserOrServicePrincipalDirectory(r.ID, "/Users") {
1588+
r.Mode = "data"
1589+
}
1590+
1591+
return nil
1592+
1593+
},
1594+
Body: resourceOrDataBlockBody,
1595+
Depends: []reference{
1596+
{Path: "path", Resource: "databricks_user", Match: "home", MatchType: MatchPrefix},
1597+
{Path: "path", Resource: "databricks_service_principal", Match: "home", MatchType: MatchPrefix},
1598+
},
1599+
},
15181600
}

exporter/importables_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,51 @@ func TestNotebookGeneration(t *testing.T) {
783783
})
784784
}
785785

786+
func TestDirectoryGeneration(t *testing.T) {
787+
testGenerate(t, []qa.HTTPFixture{
788+
{
789+
Method: "GET",
790+
Resource: "/api/2.0/workspace/list?path=%2F",
791+
Response: workspace.ObjectList{
792+
Objects: []workspace.ObjectStatus{
793+
{
794+
ObjectID: 1234,
795+
Path: "/first",
796+
ObjectType: "DIRECTORY",
797+
},
798+
},
799+
},
800+
},
801+
{
802+
Method: "GET",
803+
Resource: "/api/2.0/workspace/list?path=%2Ffirst",
804+
Response: workspace.ObjectList{
805+
Objects: []workspace.ObjectStatus{
806+
{},
807+
},
808+
},
809+
},
810+
{
811+
Method: "GET",
812+
Resource: "/api/2.0/workspace/get-status?path=%2Ffirst",
813+
Response: workspace.ObjectStatus{
814+
ObjectID: 1234,
815+
ObjectType: "DIRECTORY",
816+
Path: "/first",
817+
},
818+
},
819+
}, "notebooks", func(ic *importContext) {
820+
err := resourcesMap["databricks_directory"].List(ic)
821+
assert.NoError(t, err)
822+
823+
ic.generateHclForResources(nil)
824+
assert.Equal(t, commands.TrimLeadingWhitespace(`
825+
resource "databricks_directory" "first_1234" {
826+
path = "/first"
827+
}`), string(ic.Files["notebooks"].Bytes()))
828+
})
829+
}
830+
786831
func TestGlobalInitScriptGen(t *testing.T) {
787832
testGenerate(t, []qa.HTTPFixture{
788833
{
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"object_id": "/notebooks/123",
3+
"object_type": "notebook",
4+
"access_control_list": [
5+
{
6+
"user_name": "[email protected]",
7+
"all_permissions": [
8+
{
9+
"permission_level": "CAN_RUN",
10+
"inherited": true,
11+
"inherited_from_object": "/directories/"
12+
}
13+
]
14+
}
15+
]
16+
}

exporter/util.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,24 @@ func (ic *importContext) emitUserOrServicePrincipalForPath(path, prefix string)
106106
}
107107
}
108108

109+
func (ic *importContext) IsUserOrServicePrincipalDirectory(path, prefix string) bool {
110+
if strings.HasPrefix(path, prefix) {
111+
parts := strings.SplitN(path, "/", 4)
112+
if len(parts) == 3 {
113+
userOrSPName := parts[2]
114+
if strings.Contains(userOrSPName, "@") || uuidRegex.MatchString(userOrSPName) {
115+
return true
116+
} else {
117+
return false
118+
}
119+
} else {
120+
return false
121+
}
122+
} else {
123+
return false
124+
}
125+
}
126+
109127
func (ic *importContext) emitNotebookOrRepo(path string) {
110128
if strings.HasPrefix(path, "/Repos") {
111129
ic.Emit(&resource{

exporter/util_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,30 @@ func TestEmitNotebookOrRepo(t *testing.T) {
8484
assert.True(t, len(ic.testEmits) == 1)
8585
assert.True(t, ic.testEmits["databricks_repo[<unknown>] (path: /Repos/[email protected]/repo)"])
8686
}
87+
88+
func TestIsUserOrServicePrincipalDirectory(t *testing.T) {
89+
90+
ic := importContextForTest()
91+
result_false_partslength_more_than_3 := ic.IsUserOrServicePrincipalDirectory("/Users/[email protected]/abc", "/Users")
92+
assert.False(t, result_false_partslength_more_than_3)
93+
94+
ic = importContextForTest()
95+
result_false_partslength_less_than_3 := ic.IsUserOrServicePrincipalDirectory("/Users", "/Users")
96+
assert.False(t, result_false_partslength_less_than_3)
97+
98+
ic = importContextForTest()
99+
result_false_part2_empty := ic.IsUserOrServicePrincipalDirectory("/Users/", "/Users")
100+
assert.False(t, result_false_part2_empty)
101+
102+
ic = importContextForTest()
103+
result_false_notprefix_with_user := ic.IsUserOrServicePrincipalDirectory("/Shared", "/Users")
104+
assert.False(t, result_false_notprefix_with_user)
105+
106+
ic = importContextForTest()
107+
result_true_user_directory := ic.IsUserOrServicePrincipalDirectory("/Users/[email protected]", "/Users")
108+
assert.True(t, result_true_user_directory)
109+
110+
ic = importContextForTest()
111+
result_true_sp_directory := ic.IsUserOrServicePrincipalDirectory("/Users/0e561119-c5a0-4f29-b246-5a953adb9575", "/Users")
112+
assert.True(t, result_true_sp_directory)
113+
}

permissions/resource_permissions_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,3 +1527,86 @@ func TestResourcePermissionsUpdate_Sql_Queries(t *testing.T) {
15271527
assert.Equal(t, TestingUser, firstElem["user_name"])
15281528
assert.Equal(t, "CAN_RUN", firstElem["permission_level"])
15291529
}
1530+
1531+
func TestResourcePermissionsCreate_DirectoryPath(t *testing.T) {
1532+
d, err := qa.ResourceFixture{
1533+
Fixtures: []qa.HTTPFixture{
1534+
me,
1535+
{
1536+
Method: http.MethodGet,
1537+
Resource: "/api/2.0/workspace/get-status?path=%2FFirst",
1538+
Response: workspace.ObjectStatus{
1539+
ObjectID: 123456,
1540+
ObjectType: "directory",
1541+
},
1542+
},
1543+
{
1544+
Method: http.MethodPut,
1545+
Resource: "/api/2.0/permissions/directories/123456",
1546+
ExpectedRequest: AccessControlChangeList{
1547+
AccessControlList: []AccessControlChange{
1548+
{
1549+
UserName: TestingUser,
1550+
PermissionLevel: "CAN_READ",
1551+
},
1552+
},
1553+
},
1554+
},
1555+
{
1556+
Method: http.MethodGet,
1557+
Resource: "/api/2.0/permissions/directories/123456",
1558+
Response: ObjectACL{
1559+
ObjectID: "/directories/123456",
1560+
ObjectType: "directory",
1561+
AccessControlList: []AccessControl{
1562+
{
1563+
UserName: TestingUser,
1564+
AllPermissions: []Permission{
1565+
{
1566+
PermissionLevel: "CAN_READ",
1567+
Inherited: false,
1568+
},
1569+
},
1570+
},
1571+
{
1572+
UserName: TestingAdminUser,
1573+
AllPermissions: []Permission{
1574+
{
1575+
PermissionLevel: "CAN_RUN",
1576+
Inherited: false,
1577+
},
1578+
},
1579+
},
1580+
{
1581+
UserName: TestingAdminUser,
1582+
AllPermissions: []Permission{
1583+
{
1584+
PermissionLevel: "CAN_MANAGE",
1585+
Inherited: false,
1586+
},
1587+
},
1588+
},
1589+
},
1590+
},
1591+
},
1592+
},
1593+
Resource: ResourcePermissions(),
1594+
State: map[string]any{
1595+
"directory_path": "/First",
1596+
"access_control": []any{
1597+
map[string]any{
1598+
"user_name": TestingUser,
1599+
"permission_level": "CAN_READ",
1600+
},
1601+
},
1602+
},
1603+
Create: true,
1604+
}.Apply(t)
1605+
1606+
assert.NoError(t, err, err)
1607+
ac := d.Get("access_control").(*schema.Set)
1608+
require.Equal(t, 1, len(ac.List()))
1609+
firstElem := ac.List()[0].(map[string]any)
1610+
assert.Equal(t, TestingUser, firstElem["user_name"])
1611+
assert.Equal(t, "CAN_READ", firstElem["permission_level"])
1612+
}

workspace/resource_notebook.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,35 @@ func (a NotebooksAPI) recursiveAddPaths(path string, pathList *[]ObjectStatus) e
161161
return err
162162
}
163163

164+
func (a NotebooksAPI) ListDirectories(path string, recursive bool) ([]ObjectStatus, error) {
165+
if recursive {
166+
var paths []ObjectStatus
167+
err := a.recursiveAddDirectoryPaths(path, &paths)
168+
if err != nil {
169+
return nil, err
170+
}
171+
return paths, err
172+
}
173+
return a.list(path)
174+
}
175+
176+
func (a NotebooksAPI) recursiveAddDirectoryPaths(path string, pathList *[]ObjectStatus) error {
177+
directoryInfoList, err := a.list(path)
178+
if err != nil {
179+
return err
180+
}
181+
for _, v := range directoryInfoList {
182+
if v.ObjectType == Directory {
183+
*pathList = append(*pathList, v)
184+
err := a.recursiveAddDirectoryPaths(v.Path, pathList)
185+
if err != nil {
186+
return err
187+
}
188+
}
189+
}
190+
return err
191+
}
192+
164193
type ObjectList struct {
165194
Objects []ObjectStatus `json:"objects,omitempty"`
166195
}

0 commit comments

Comments
 (0)