Skip to content

Commit f14198b

Browse files
Add resource databricks_grant for managing singular principal (#3024)
* Add resource "databricks_grant" for managing singular principal * resource_grant: tweak method names to avoid clash with resource_grants * resource_grants: refactor internals to be common with resource_grant * internal/acceptance/schema_test.go: Add acceptance test for databricks_grant resource * resource_grant: Append principal to ID for uniqueness * resource_grants: Remove outdated comment * resource_grant: Use StructToSchema function & add some tests to cover field validation * resource_grant: flip requiresnew test for principal * docs: add docco for databricks_grant & run fmt-docs * Update docs/resources/grants.md Co-authored-by: vuong-nguyen <[email protected]> * resource_grant: remove validation of priveleges to securable * catalog/resource_grants.go: revert refactor as code diverges with resource_grant.go * resource_grant: refactor to use sdk * resource_grant: use type rather than raw string for securable * resource_grant: Port changes from #3026 for resource_grants * resource_grant: Port changes from #3034 for resource_grants acceptance tests * Add missing link on readme * resource_grant: Handle special case for "model" which is documented & supported by resource_grants * Update docs/resources/grant.md Co-authored-by: vuong-nguyen <[email protected]> * Update docs/resources/grant.md Co-authored-by: vuong-nguyen <[email protected]> * docs/resources/grant.md: reference grants.md for list of permissions * resource_grants: review comments * resource_grants: remove redundant ToPrivilegeSlice function * resource_grants: address review comment to preserve type and add extra defensive check * resource_grant: address review comment to encapsulate ID wrangling & clean up usage * Update internal/acceptance/grant_test.go Co-authored-by: vuong-nguyen <[email protected]> --------- Co-authored-by: vuong-nguyen <[email protected]>
1 parent ebfc026 commit f14198b

File tree

12 files changed

+1541
-4
lines changed

12 files changed

+1541
-4
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
| [databricks_external_location](docs/resources/external_location.md)
2525
| [databricks_git_credential](docs/resources/git_credential.md)
2626
| [databricks_global_init_script](docs/resources/global_init_script.md)
27+
| [databricks_grant](docs/resources/grant.md)
2728
| [databricks_grants](docs/resources/grants.md)
2829
| [databricks_group](docs/resources/group.md)
2930
| [databricks_group](docs/data-sources/group.md) data

catalog/permissions/permissions.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package permissions
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"time"
8+
9+
"github.com/databricks/databricks-sdk-go"
10+
"github.com/databricks/databricks-sdk-go/service/catalog"
11+
"github.com/databricks/databricks-sdk-go/service/sharing"
12+
"github.com/databricks/terraform-provider-databricks/common"
13+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
14+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
15+
)
16+
17+
// API
18+
type UnityCatalogPermissionsAPI struct {
19+
client *databricks.WorkspaceClient
20+
context context.Context
21+
}
22+
23+
func NewUnityCatalogPermissionsAPI(ctx context.Context, m any) UnityCatalogPermissionsAPI {
24+
client, _ := m.(*common.DatabricksClient).WorkspaceClient()
25+
return UnityCatalogPermissionsAPI{client, ctx}
26+
}
27+
28+
func (a UnityCatalogPermissionsAPI) GetPermissions(securable catalog.SecurableType, name string) (list *catalog.PermissionsList, err error) {
29+
if securable.String() == "share" {
30+
list, err = a.client.Shares.SharePermissions(a.context, sharing.SharePermissionsRequest{name})
31+
return
32+
}
33+
list, err = a.client.Grants.GetBySecurableTypeAndFullName(a.context, securable, name)
34+
return
35+
}
36+
37+
func (a UnityCatalogPermissionsAPI) UpdatePermissions(securable catalog.SecurableType, name string, diff []catalog.PermissionsChange) error {
38+
if securable.String() == "share" {
39+
return a.client.Shares.UpdatePermissions(a.context, sharing.UpdateSharePermissions{
40+
Changes: diff,
41+
Name: name,
42+
})
43+
}
44+
_, err := a.client.Grants.Update(a.context, catalog.UpdatePermissions{
45+
Changes: diff,
46+
SecurableType: securable,
47+
FullName: name,
48+
})
49+
return err
50+
}
51+
52+
func (a UnityCatalogPermissionsAPI) WaitForUpdate(timeout time.Duration, securable catalog.SecurableType, name string, desired catalog.PermissionsList, diff func(*catalog.PermissionsList, catalog.PermissionsList) []catalog.PermissionsChange) error {
53+
return retry.RetryContext(a.context, timeout, func() *retry.RetryError {
54+
current, err := a.GetPermissions(securable, name)
55+
if err != nil {
56+
return retry.NonRetryableError(err)
57+
}
58+
log.Printf("[DEBUG] Permissions for %s-%s are: %v", securable.String(), name, current)
59+
if diff(current, desired) == nil {
60+
return nil
61+
}
62+
return retry.RetryableError(
63+
fmt.Errorf("permissions for %s-%s are %v, but have to be %v", securable.String(), name, current, desired),
64+
)
65+
})
66+
}
67+
68+
// Terraform Schema
69+
type UnityCatalogPrivilegeAssignment struct {
70+
Principal string `json:"principal"`
71+
Privileges []string `json:"privileges" tf:"slice_set"`
72+
}
73+
74+
// Permission Mappings
75+
76+
type SecurableMapping map[string]catalog.SecurableType
77+
78+
// reuse ResourceDiff and ResourceData
79+
type attributeGetter interface {
80+
Get(key string) any
81+
}
82+
83+
func (sm SecurableMapping) GetSecurableType(securable string) catalog.SecurableType {
84+
return sm[securable]
85+
}
86+
87+
func (sm SecurableMapping) KeyValue(d attributeGetter) (string, string) {
88+
for field := range sm {
89+
v := d.Get(field).(string)
90+
if v == "" {
91+
continue
92+
}
93+
return field, v
94+
}
95+
return "unknown", "unknown"
96+
}
97+
func (sm SecurableMapping) Id(d *schema.ResourceData) string {
98+
securable, name := sm.KeyValue(d)
99+
return fmt.Sprintf("%s/%s", securable, name)
100+
}
101+
102+
// Mappings
103+
// See https://docs.databricks.com/api/workspace/grants/update for full list
104+
// Omitting provider as a reserved keyword
105+
var Mappings = SecurableMapping{
106+
"catalog": catalog.SecurableType("catalog"),
107+
"foreign_connection": catalog.SecurableType("connection"),
108+
"external_location": catalog.SecurableType("external_location"),
109+
"function": catalog.SecurableType("function"),
110+
"metastore": catalog.SecurableType("metastore"),
111+
"model": catalog.SecurableType("function"),
112+
"pipeline": catalog.SecurableType("pipeline"),
113+
"recipient": catalog.SecurableType("recipient"),
114+
"schema": catalog.SecurableType("schema"),
115+
"share": catalog.SecurableType("share"),
116+
"storage_credential": catalog.SecurableType("storage_credential"),
117+
"table": catalog.SecurableType("table"),
118+
"volume": catalog.SecurableType("volume"),
119+
}
120+
121+
// Utils for Slice and Set
122+
func SliceToSet(in []catalog.Privilege) *schema.Set {
123+
var out []any
124+
for _, v := range in {
125+
out = append(out, v.String())
126+
}
127+
return schema.NewSet(schema.HashString, out)
128+
}
129+
130+
func SetToSlice(set *schema.Set) (ss []catalog.Privilege) {
131+
for _, v := range set.List() {
132+
ss = append(ss, catalog.Privilege(v.(string)))
133+
}
134+
return
135+
}
136+
137+
func SliceWithoutString(in []string, without string) (out []string) {
138+
for _, v := range in {
139+
if v == without {
140+
continue
141+
}
142+
out = append(out, v)
143+
}
144+
return
145+
}

catalog/resource_grant.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package catalog
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"sort"
8+
"strings"
9+
"time"
10+
11+
"github.com/databricks/databricks-sdk-go/apierr"
12+
"github.com/databricks/databricks-sdk-go/service/catalog"
13+
"github.com/databricks/terraform-provider-databricks/catalog/permissions"
14+
"github.com/databricks/terraform-provider-databricks/common"
15+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
16+
)
17+
18+
// diffPermissionsForPrincipal returns an array of catalog.PermissionsChange of this permissions list with `diff` privileges removed
19+
func diffPermissionsForPrincipal(principal string, desired catalog.PermissionsList, existing catalog.PermissionsList) (diff []catalog.PermissionsChange) {
20+
// diffs change sets for principal
21+
configured := map[string]*schema.Set{}
22+
for _, v := range desired.PrivilegeAssignments {
23+
if v.Principal == principal {
24+
configured[v.Principal] = permissions.SliceToSet(v.Privileges)
25+
}
26+
}
27+
// existing permissions that needs removal for principal
28+
remote := map[string]*schema.Set{}
29+
for _, v := range existing.PrivilegeAssignments {
30+
if v.Principal == principal {
31+
remote[v.Principal] = permissions.SliceToSet(v.Privileges)
32+
}
33+
}
34+
// STEP 1: detect overlaps
35+
for principal, confPrivs := range configured {
36+
remotePrivs, ok := remote[principal]
37+
if !ok {
38+
remotePrivs = permissions.SliceToSet([]catalog.Privilege{})
39+
}
40+
add := permissions.SetToSlice(confPrivs.Difference(remotePrivs))
41+
remove := permissions.SetToSlice(remotePrivs.Difference(confPrivs))
42+
if len(add) == 0 && len(remove) == 0 {
43+
continue
44+
}
45+
diff = append(diff, catalog.PermissionsChange{
46+
Principal: principal,
47+
Add: add,
48+
Remove: remove,
49+
})
50+
}
51+
// STEP 2: non overlap - simply remove
52+
for principal, remove := range remote {
53+
_, ok := configured[principal]
54+
if ok { // already handled in STEP 1
55+
continue
56+
}
57+
diff = append(diff, catalog.PermissionsChange{
58+
Principal: principal,
59+
Remove: permissions.SetToSlice(remove),
60+
})
61+
}
62+
// so that we can deterministic tests
63+
sort.Slice(diff, func(i, j int) bool {
64+
return diff[i].Principal < diff[j].Principal
65+
})
66+
return diff
67+
}
68+
69+
// replacePermissionsForPrincipal merges removal diff of existing permissions on the platform
70+
func replacePermissionsForPrincipal(a permissions.UnityCatalogPermissionsAPI, securable string, name string, principal string, list catalog.PermissionsList) error {
71+
securableType := permissions.Mappings.GetSecurableType(securable)
72+
existing, err := a.GetPermissions(securableType, name)
73+
if err != nil {
74+
return err
75+
}
76+
err = a.UpdatePermissions(securableType, name, diffPermissionsForPrincipal(principal, list, *existing))
77+
if err != nil {
78+
return err
79+
}
80+
return a.WaitForUpdate(1*time.Minute, securableType, name, list, func(current *catalog.PermissionsList, desired catalog.PermissionsList) []catalog.PermissionsChange {
81+
return diffPermissionsForPrincipal(principal, desired, *current)
82+
})
83+
}
84+
85+
// filterPermissionsForPrincipal extracts permissions for the given principal and transforms to permissions.UnityCatalogPrivilegeAssignment to match Schema
86+
func filterPermissionsForPrincipal(in catalog.PermissionsList, principal string) (*permissions.UnityCatalogPrivilegeAssignment, error) {
87+
grantsForPrincipal := []permissions.UnityCatalogPrivilegeAssignment{}
88+
for _, v := range in.PrivilegeAssignments {
89+
privileges := []string{}
90+
if v.Principal == principal {
91+
for _, p := range v.Privileges {
92+
privileges = append(privileges, p.String())
93+
}
94+
grantsForPrincipal = append(grantsForPrincipal, permissions.UnityCatalogPrivilegeAssignment{
95+
Principal: v.Principal,
96+
Privileges: privileges,
97+
})
98+
}
99+
}
100+
if len(grantsForPrincipal) == 0 {
101+
return nil, apierr.NotFound("got empty permissions list")
102+
}
103+
if len(grantsForPrincipal) > 1 {
104+
return nil, errors.New("got more than one principal in permissions list")
105+
}
106+
return &grantsForPrincipal[0], nil
107+
}
108+
109+
func toSecurableId(d *schema.ResourceData) string {
110+
principal := d.Get("principal").(string)
111+
return fmt.Sprintf("%s/%s", permissions.Mappings.Id(d), principal)
112+
}
113+
114+
func parseSecurableId(d *schema.ResourceData) (string, string, string, error) {
115+
split := strings.SplitN(d.Id(), "/", 3)
116+
if len(split) != 3 {
117+
return "", "", "", fmt.Errorf("ID must be three elements split by `/`: %s", d.Id())
118+
}
119+
return split[0], split[1], split[2], nil
120+
}
121+
122+
func ResourceGrant() *schema.Resource {
123+
s := common.StructToSchema(permissions.UnityCatalogPrivilegeAssignment{},
124+
func(m map[string]*schema.Schema) map[string]*schema.Schema {
125+
126+
m["principal"].ForceNew = true
127+
128+
allFields := []string{}
129+
for field := range permissions.Mappings {
130+
allFields = append(allFields, field)
131+
}
132+
for field := range permissions.Mappings {
133+
m[field] = &schema.Schema{
134+
Type: schema.TypeString,
135+
Optional: true,
136+
ForceNew: true,
137+
AtLeastOneOf: allFields,
138+
ConflictsWith: permissions.SliceWithoutString(allFields, field),
139+
}
140+
}
141+
return m
142+
})
143+
144+
return common.Resource{
145+
Schema: s,
146+
Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error {
147+
principal := d.Get("principal").(string)
148+
privileges := permissions.SetToSlice(d.Get("privileges").(*schema.Set))
149+
var grants = catalog.PermissionsList{
150+
PrivilegeAssignments: []catalog.PrivilegeAssignment{
151+
{
152+
Principal: principal,
153+
Privileges: privileges,
154+
},
155+
},
156+
}
157+
securable, name := permissions.Mappings.KeyValue(d)
158+
unityCatalogPermissionsAPI := permissions.NewUnityCatalogPermissionsAPI(ctx, c)
159+
err := replacePermissionsForPrincipal(unityCatalogPermissionsAPI, securable, name, principal, grants)
160+
if err != nil {
161+
return err
162+
}
163+
d.SetId(toSecurableId(d))
164+
return nil
165+
},
166+
Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error {
167+
securable, name, principal, err := parseSecurableId(d)
168+
if err != nil {
169+
return err
170+
}
171+
grants, err := permissions.NewUnityCatalogPermissionsAPI(ctx, c).GetPermissions(permissions.Mappings.GetSecurableType(securable), name)
172+
if err != nil {
173+
return err
174+
}
175+
grantsForPrincipal, err := filterPermissionsForPrincipal(*grants, principal)
176+
if err != nil {
177+
return err
178+
}
179+
return common.StructToData(*grantsForPrincipal, s, d)
180+
},
181+
Update: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error {
182+
securable, name, principal, err := parseSecurableId(d)
183+
if err != nil {
184+
return err
185+
}
186+
privileges := permissions.SetToSlice(d.Get("privileges").(*schema.Set))
187+
var grants = catalog.PermissionsList{
188+
PrivilegeAssignments: []catalog.PrivilegeAssignment{
189+
{
190+
Principal: principal,
191+
Privileges: privileges,
192+
},
193+
},
194+
}
195+
unityCatalogPermissionsAPI := permissions.NewUnityCatalogPermissionsAPI(ctx, c)
196+
return replacePermissionsForPrincipal(unityCatalogPermissionsAPI, securable, name, principal, grants)
197+
},
198+
Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error {
199+
securable, name, principal, err := parseSecurableId(d)
200+
if err != nil {
201+
return err
202+
}
203+
unityCatalogPermissionsAPI := permissions.NewUnityCatalogPermissionsAPI(ctx, c)
204+
return replacePermissionsForPrincipal(unityCatalogPermissionsAPI, securable, name, principal, catalog.PermissionsList{})
205+
},
206+
}.ToResource()
207+
}

0 commit comments

Comments
 (0)