-
Notifications
You must be signed in to change notification settings - Fork 398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add resource databricks_grant
for managing singular principal
#3024
Changes from 35 commits
6b87ac0
e396c2f
9db1a78
eb463a0
9a303e7
9a3a883
373df83
d7ecad4
661ff8f
a0594bd
d335ba4
4ffb491
b89003d
502738c
976c43b
70ec119
a464942
0dbeba6
7dab1a6
83e29e1
e5788bc
c5c7ce4
d5a89a7
e6cb98d
00654ee
bd51a54
f72dc15
29a937b
7c88c7d
d588698
837951b
d360f98
db8bf17
5ad2aed
2690df9
11ee7a4
c7ce0f4
a0044d2
85332a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
package permissions | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"log" | ||
"time" | ||
|
||
"github.com/databricks/databricks-sdk-go" | ||
"github.com/databricks/databricks-sdk-go/service/catalog" | ||
"github.com/databricks/databricks-sdk-go/service/sharing" | ||
"github.com/databricks/terraform-provider-databricks/common" | ||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" | ||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" | ||
) | ||
|
||
// API | ||
type UnityCatalogPermissionsAPI struct { | ||
client *databricks.WorkspaceClient | ||
context context.Context | ||
} | ||
|
||
func NewUnityCatalogPermissionsAPI(ctx context.Context, m any) UnityCatalogPermissionsAPI { | ||
client, _ := m.(*common.DatabricksClient).WorkspaceClient() | ||
return UnityCatalogPermissionsAPI{client, ctx} | ||
} | ||
|
||
func (a UnityCatalogPermissionsAPI) GetPermissions(securable catalog.SecurableType, name string) (list *catalog.PermissionsList, err error) { | ||
if securable.String() == "share" { | ||
list, err = a.client.Shares.SharePermissions(a.context, sharing.SharePermissionsRequest{name}) | ||
return | ||
} | ||
list, err = a.client.Grants.GetBySecurableTypeAndFullName(a.context, securable, name) | ||
return | ||
} | ||
|
||
func (a UnityCatalogPermissionsAPI) UpdatePermissions(securable catalog.SecurableType, name string, diff []catalog.PermissionsChange) error { | ||
if securable.String() == "share" { | ||
return a.client.Shares.UpdatePermissions(a.context, sharing.UpdateSharePermissions{ | ||
Changes: diff, | ||
Name: name, | ||
}) | ||
} | ||
_, err := a.client.Grants.Update(a.context, catalog.UpdatePermissions{ | ||
Changes: diff, | ||
SecurableType: securable, | ||
FullName: name, | ||
}) | ||
return err | ||
} | ||
|
||
func (a UnityCatalogPermissionsAPI) WaitForUpdate(timeout time.Duration, securable catalog.SecurableType, name string, desired catalog.PermissionsList, diff func(*catalog.PermissionsList, catalog.PermissionsList) []catalog.PermissionsChange) error { | ||
return retry.RetryContext(a.context, timeout, func() *retry.RetryError { | ||
current, err := a.GetPermissions(securable, name) | ||
if err != nil { | ||
return retry.NonRetryableError(err) | ||
} | ||
log.Printf("[DEBUG] Permissions for %s-%s are: %v", securable.String(), name, current) | ||
if diff(current, desired) == nil { | ||
return nil | ||
} | ||
return retry.RetryableError( | ||
fmt.Errorf("permissions for %s-%s are %v, but have to be %v", securable.String(), name, current, desired), | ||
) | ||
}) | ||
} | ||
|
||
// Terraform Schema | ||
type UnityCatalogPrivilegeAssignment struct { | ||
Principal string `json:"principal"` | ||
Privileges []string `json:"privileges" tf:"slice_set"` | ||
} | ||
|
||
// Permission Mappings | ||
|
||
type SecurableMapping map[string]catalog.SecurableType | ||
|
||
// reuse ResourceDiff and ResourceData | ||
type attributeGetter interface { | ||
Get(key string) any | ||
} | ||
|
||
func (sm SecurableMapping) GetSecurableType(securable string) catalog.SecurableType { | ||
return sm[securable] | ||
} | ||
|
||
func (sm SecurableMapping) KeyValue(d attributeGetter) (string, string) { | ||
for field := range sm { | ||
v := d.Get(field).(string) | ||
if v == "" { | ||
continue | ||
} | ||
return field, v | ||
} | ||
return "unknown", "unknown" | ||
} | ||
func (sm SecurableMapping) Id(d *schema.ResourceData) string { | ||
securable, name := sm.KeyValue(d) | ||
return fmt.Sprintf("%s/%s", securable, name) | ||
} | ||
|
||
// Mappings | ||
// See https://docs.databricks.com/api/workspace/grants/update for full list | ||
// Omitting provider as a reserved keyword | ||
var Mappings = SecurableMapping{ | ||
"catalog": catalog.SecurableType("catalog"), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not for this PR, but a general remark: seems like it would be useful for the SDK itself to expose a function that returns all enum values for an enum type. |
||
"foreign_connection": catalog.SecurableType("connection"), | ||
"external_location": catalog.SecurableType("external_location"), | ||
"function": catalog.SecurableType("function"), | ||
"metastore": catalog.SecurableType("metastore"), | ||
"model": catalog.SecurableType("function"), | ||
"pipeline": catalog.SecurableType("pipeline"), | ||
"recipient": catalog.SecurableType("recipient"), | ||
"schema": catalog.SecurableType("schema"), | ||
"share": catalog.SecurableType("share"), | ||
"storage_credential": catalog.SecurableType("storage_credential"), | ||
"table": catalog.SecurableType("table"), | ||
"volume": catalog.SecurableType("volume"), | ||
} | ||
|
||
// Utils for Slice and Set | ||
func SliceToSet(in []catalog.Privilege) *schema.Set { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are nice! I'm going to think about a way to generalize these so you don't have to implement them for every type. This is great for now though. |
||
var out []any | ||
for _, v := range in { | ||
out = append(out, v.String()) | ||
} | ||
return schema.NewSet(schema.HashString, out) | ||
} | ||
|
||
func SetToSlice(set *schema.Set) (ss []catalog.Privilege) { | ||
for _, v := range set.List() { | ||
ss = append(ss, catalog.Privilege(v.(string))) | ||
} | ||
return | ||
} | ||
|
||
func SliceWithoutString(in []string, without string) (out []string) { | ||
martin-walsh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
for _, v := range in { | ||
if v == without { | ||
continue | ||
} | ||
out = append(out, v) | ||
} | ||
return | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
package catalog | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"sort" | ||
"strings" | ||
"time" | ||
|
||
"github.com/databricks/databricks-sdk-go/apierr" | ||
"github.com/databricks/databricks-sdk-go/service/catalog" | ||
"github.com/databricks/terraform-provider-databricks/catalog/permissions" | ||
"github.com/databricks/terraform-provider-databricks/common" | ||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" | ||
) | ||
|
||
// diffPermissionsForPrincipal returns an array of catalog.PermissionsChange of this permissions list with `diff` privileges removed | ||
func diffPermissionsForPrincipal(principal string, desired catalog.PermissionsList, existing catalog.PermissionsList) (diff []catalog.PermissionsChange) { | ||
// diffs change sets for principal | ||
configured := map[string]*schema.Set{} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe I'm misreading this, but because this function only ever operates on one principal, this map will only have one key defined? If so, we can just use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ping here |
||
for _, v := range desired.PrivilegeAssignments { | ||
if v.Principal == principal { | ||
configured[v.Principal] = permissions.SliceToSet(v.Privileges) | ||
} | ||
} | ||
// existing permissions that needs removal for principal | ||
remote := map[string]*schema.Set{} | ||
for _, v := range existing.PrivilegeAssignments { | ||
if v.Principal == principal { | ||
remote[v.Principal] = permissions.SliceToSet(v.Privileges) | ||
} | ||
} | ||
// STEP 1: detect overlaps | ||
for principal, confPrivs := range configured { | ||
remotePrivs, ok := remote[principal] | ||
if !ok { | ||
remotePrivs = permissions.SliceToSet([]catalog.Privilege{}) | ||
} | ||
add := permissions.SetToSlice(confPrivs.Difference(remotePrivs)) | ||
remove := permissions.SetToSlice(remotePrivs.Difference(confPrivs)) | ||
if len(add) == 0 && len(remove) == 0 { | ||
continue | ||
} | ||
diff = append(diff, catalog.PermissionsChange{ | ||
Principal: principal, | ||
Add: add, | ||
Remove: remove, | ||
}) | ||
} | ||
// STEP 2: non overlap - simply remove | ||
for principal, remove := range remote { | ||
_, ok := configured[principal] | ||
if ok { // already handled in STEP 1 | ||
continue | ||
} | ||
diff = append(diff, catalog.PermissionsChange{ | ||
Principal: principal, | ||
Remove: permissions.SetToSlice(remove), | ||
}) | ||
} | ||
// so that we can deterministic tests | ||
sort.Slice(diff, func(i, j int) bool { | ||
return diff[i].Principal < diff[j].Principal | ||
}) | ||
return diff | ||
} | ||
|
||
// replacePermissionsForPrincipal merges removal diff of existing permissions on the platform | ||
func replacePermissionsForPrincipal(a permissions.UnityCatalogPermissionsAPI, securable string, name string, principal string, list catalog.PermissionsList) error { | ||
securableType := permissions.Mappings.GetSecurableType(securable) | ||
existing, err := a.GetPermissions(securableType, name) | ||
if err != nil { | ||
return err | ||
} | ||
err = a.UpdatePermissions(securableType, name, diffPermissionsForPrincipal(principal, list, *existing)) | ||
if err != nil { | ||
return err | ||
} | ||
return a.WaitForUpdate(1*time.Minute, securableType, name, list, func(current *catalog.PermissionsList, desired catalog.PermissionsList) []catalog.PermissionsChange { | ||
return diffPermissionsForPrincipal(principal, desired, *current) | ||
}) | ||
} | ||
|
||
// filterPermissionsForPrincipal extracts permissions for the given principal and transforms to permissions.UnityCatalogPrivilegeAssignment to match Schema | ||
func filterPermissionsForPrincipal(in catalog.PermissionsList, principal string) (*permissions.UnityCatalogPrivilegeAssignment, error) { | ||
grantsForPrincipal := []permissions.UnityCatalogPrivilegeAssignment{} | ||
for _, v := range in.PrivilegeAssignments { | ||
privileges := []string{} | ||
if v.Principal == principal { | ||
for _, p := range v.Privileges { | ||
privileges = append(privileges, p.String()) | ||
} | ||
grantsForPrincipal = append(grantsForPrincipal, permissions.UnityCatalogPrivilegeAssignment{ | ||
Principal: v.Principal, | ||
Privileges: privileges, | ||
}) | ||
} | ||
} | ||
if len(grantsForPrincipal) == 0 { | ||
return nil, apierr.NotFound("got empty permissions list") | ||
} | ||
if len(grantsForPrincipal) > 1 { | ||
return nil, errors.New("got more than one principal in permissions list") | ||
} | ||
return &grantsForPrincipal[0], nil | ||
} | ||
|
||
func toSecurableId(d *schema.ResourceData) string { | ||
principal := d.Get("principal").(string) | ||
return fmt.Sprintf("%s/%s", permissions.Mappings.Id(d), principal) | ||
} | ||
|
||
func parseSecurableId(d *schema.ResourceData) (string, string, string, error) { | ||
split := strings.SplitN(d.Id(), "/", 3) | ||
if len(split) != 3 { | ||
return "", "", "", fmt.Errorf("ID must be three elements split by `/`: %s", d.Id()) | ||
} | ||
return split[0], split[1], split[2], nil | ||
} | ||
|
||
func ResourceGrant() *schema.Resource { | ||
s := common.StructToSchema(permissions.UnityCatalogPrivilegeAssignment{}, | ||
func(m map[string]*schema.Schema) map[string]*schema.Schema { | ||
|
||
m["principal"].ForceNew = true | ||
|
||
allFields := []string{} | ||
for field := range permissions.Mappings { | ||
allFields = append(allFields, field) | ||
} | ||
for field := range permissions.Mappings { | ||
m[field] = &schema.Schema{ | ||
Type: schema.TypeString, | ||
Optional: true, | ||
ForceNew: true, | ||
AtLeastOneOf: allFields, | ||
ConflictsWith: permissions.SliceWithoutString(allFields, field), | ||
} | ||
} | ||
return m | ||
}) | ||
|
||
return common.Resource{ | ||
Schema: s, | ||
Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { | ||
principal := d.Get("principal").(string) | ||
privileges := permissions.SetToSlice(d.Get("privileges").(*schema.Set)) | ||
var grants = catalog.PermissionsList{ | ||
PrivilegeAssignments: []catalog.PrivilegeAssignment{ | ||
{ | ||
Principal: principal, | ||
Privileges: privileges, | ||
}, | ||
}, | ||
} | ||
securable, name := permissions.Mappings.KeyValue(d) | ||
unityCatalogPermissionsAPI := permissions.NewUnityCatalogPermissionsAPI(ctx, c) | ||
martin-walsh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
err := replacePermissionsForPrincipal(unityCatalogPermissionsAPI, securable, name, principal, grants) | ||
if err != nil { | ||
return err | ||
} | ||
d.SetId(toSecurableId(d)) | ||
return nil | ||
}, | ||
Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { | ||
securable, name, principal, err := parseSecurableId(d) | ||
if err != nil { | ||
return err | ||
} | ||
grants, err := permissions.NewUnityCatalogPermissionsAPI(ctx, c).GetPermissions(permissions.Mappings.GetSecurableType(securable), name) | ||
if err != nil { | ||
return err | ||
} | ||
grantsForPrincipal, err := filterPermissionsForPrincipal(*grants, principal) | ||
if err != nil { | ||
return err | ||
} | ||
return common.StructToData(*grantsForPrincipal, s, d) | ||
}, | ||
Update: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { | ||
securable, name, principal, err := parseSecurableId(d) | ||
if err != nil { | ||
return err | ||
} | ||
privileges := permissions.SetToSlice(d.Get("privileges").(*schema.Set)) | ||
var grants = catalog.PermissionsList{ | ||
PrivilegeAssignments: []catalog.PrivilegeAssignment{ | ||
{ | ||
Principal: principal, | ||
Privileges: privileges, | ||
}, | ||
}, | ||
} | ||
unityCatalogPermissionsAPI := permissions.NewUnityCatalogPermissionsAPI(ctx, c) | ||
return replacePermissionsForPrincipal(unityCatalogPermissionsAPI, securable, name, principal, grants) | ||
}, | ||
Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { | ||
securable, name, principal, err := parseSecurableId(d) | ||
if err != nil { | ||
return err | ||
} | ||
unityCatalogPermissionsAPI := permissions.NewUnityCatalogPermissionsAPI(ctx, c) | ||
return replacePermissionsForPrincipal(unityCatalogPermissionsAPI, securable, name, principal, catalog.PermissionsList{}) | ||
}, | ||
}.ToResource() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you comment here that this is based off of
catalog.PermissionsList
? As @nkvuong mentioned, in the future we'll refactor these resources to inherit fields from the Go SDK. Knowing what this is derived from will help us track this down in the future.