Skip to content
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

Merged
merged 39 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
6b87ac0
Add resource "databricks_grant" for managing singular principal
martin-walsh Dec 12, 2023
e396c2f
resource_grant: tweak method names to avoid clash with resource_grants
martin-walsh Dec 12, 2023
9db1a78
resource_grants: refactor internals to be common with resource_grant
martin-walsh Dec 12, 2023
eb463a0
internal/acceptance/schema_test.go: Add acceptance test for databrick…
martin-walsh Dec 12, 2023
9a303e7
resource_grant: Append principal to ID for uniqueness
martin-walsh Dec 13, 2023
9a3a883
resource_grants: Remove outdated comment
martin-walsh Dec 13, 2023
373df83
resource_grant: Use StructToSchema function & add some tests to cover…
martin-walsh Dec 14, 2023
d7ecad4
resource_grant: flip requiresnew test for principal
martin-walsh Dec 14, 2023
661ff8f
docs: add docco for databricks_grant & run fmt-docs
martin-walsh Dec 14, 2023
a0594bd
Update docs/resources/grants.md
martin-walsh Dec 14, 2023
d335ba4
resource_grant: remove validation of priveleges to securable
martin-walsh Dec 14, 2023
4ffb491
catalog/resource_grants.go: revert refactor as code diverges with res…
martin-walsh Dec 14, 2023
b89003d
resource_grant: refactor to use sdk
martin-walsh Dec 14, 2023
502738c
resource_grant: use type rather than raw string for securable
martin-walsh Dec 14, 2023
976c43b
Merge branch 'master' into resource_grant
martin-walsh Dec 17, 2023
70ec119
resource_grant: Port changes from #3026 for resource_grants
martin-walsh Dec 18, 2023
a464942
Merge branch 'master' into resource_grant
martin-walsh Dec 18, 2023
0dbeba6
resource_grant: Port changes from #3034 for resource_grants acceptanc…
martin-walsh Dec 18, 2023
7dab1a6
Add missing link on readme
martin-walsh Dec 18, 2023
83e29e1
Merge branch 'master' into resource_grant
martin-walsh Dec 19, 2023
e5788bc
resource_grant: Handle special case for "model" which is documented &…
martin-walsh Dec 19, 2023
c5c7ce4
Update docs/resources/grant.md
martin-walsh Dec 19, 2023
d5a89a7
Update docs/resources/grant.md
martin-walsh Dec 20, 2023
e6cb98d
docs/resources/grant.md: reference grants.md for list of permissions
martin-walsh Dec 20, 2023
00654ee
Merge branch 'master' into resource_grant
martin-walsh Dec 20, 2023
bd51a54
Merge branch 'master' into resource_grant
martin-walsh Dec 21, 2023
f72dc15
resource_grants: review comments
martin-walsh Dec 21, 2023
29a937b
resource_grants: remove redundant ToPrivilegeSlice function
martin-walsh Dec 21, 2023
7c88c7d
resource_grants: address review comment to preserve type and add extr…
martin-walsh Dec 21, 2023
d588698
resource_grant: address review comment to encapsulate ID wrangling & …
martin-walsh Dec 21, 2023
837951b
Merge branch 'master' into resource_grant
martin-walsh Dec 22, 2023
d360f98
Merge branch 'master' into resource_grant
martin-walsh Dec 31, 2023
db8bf17
Merge branch 'master' into resource_grant
martin-walsh Jan 1, 2024
5ad2aed
Merge branch 'master' into resource_grant
martin-walsh Jan 2, 2024
2690df9
Merge branch 'master' into resource_grant
martin-walsh Jan 3, 2024
11ee7a4
Update internal/acceptance/grant_test.go
martin-walsh Jan 5, 2024
c7ce0f4
Merge branch 'master' into resource_grant
martin-walsh Jan 5, 2024
a0044d2
Merge branch 'master' into resource_grant
martin-walsh Jan 7, 2024
85332a5
Merge branch 'master' into resource_grant
martin-walsh Jan 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
| [databricks_external_location](docs/resources/external_location.md)
| [databricks_git_credential](docs/resources/git_credential.md)
| [databricks_global_init_script](docs/resources/global_init_script.md)
| [databricks_grant](docs/resources/grant.md)
| [databricks_grants](docs/resources/grants.md)
| [databricks_group](docs/resources/group.md)
| [databricks_group](docs/data-sources/group.md) data
Expand Down
145 changes: 145 additions & 0 deletions catalog/permissions/permissions.go
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 {
Copy link
Contributor

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.

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"),
Copy link
Contributor

Choose a reason for hiding this comment

The 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The 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
}
207 changes: 207 additions & 0 deletions catalog/resource_grant.go
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{}
Copy link
Contributor

Choose a reason for hiding this comment

The 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 *schema.Set as opposed to a map. This applies to the other data structures throughout this function.

Copy link
Contributor

Choose a reason for hiding this comment

The 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()
}
Loading
Loading