Skip to content

Commit

Permalink
feat: add prefect_account_member Datasource (#85)
Browse files Browse the repository at this point in the history
* rename file

* add account memberships client + list method

* add account_member datasource

* final tweaks

* fix

* yup
  • Loading branch information
parkedwards authored Oct 30, 2023
1 parent 2b5389f commit 9510152
Show file tree
Hide file tree
Showing 10 changed files with 357 additions and 11 deletions.
15 changes: 15 additions & 0 deletions examples/account_members.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
data "prefect_account_member" "marvin" {
email = "[email protected]"
}
data "prefect_workspace" "prd" {
id = "<workspace uuid>"
}
data "prefect_workspace_role" "developer" {
name = "Developer"
}
resource "prefect_workspace_access" "marvin_developer" {
accessor_type = "USER"
accessor_id = prefect_account_member.marvin.user_id
workspace_id = data.prefect_workspace.prd.id
workspace_role_id = data.prefect_workspace_role.developer.id
}
16 changes: 8 additions & 8 deletions examples/workspace_access.tf
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
data "prefect_workspace_role" "developer" {
name = "Developer"
name = "Developer"
}
data "prefect_workspace" "prd" {
id = "<workspace uuid>"
id = "<workspace uuid>"
}
resource "prefect_service_account" "bot" {
name = "a-cool-bot"
name = "a-cool-bot"
}
resource "prefect_workspace_access" "bot_access" {
accessor_type = "SERVICE_ACCOUNT"
accessor_id = prefect_service_account.bot.id
workspace_id = data.prefect_workspace.prd.id
workspace_role_id = data.prefect_workspace_role.developer.id
resource "prefect_workspace_access" "bot_developer" {
accessor_type = "SERVICE_ACCOUNT"
accessor_id = prefect_service_account.bot.id
workspace_id = data.prefect_workspace.prd.id
workspace_role_id = data.prefect_workspace_role.developer.id
}
37 changes: 37 additions & 0 deletions internal/api/account_memberships.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package api

import (
"context"
"time"

"github.com/google/uuid"
)

type AccountMembershipsClient interface {
List(ctx context.Context, emails []string) ([]*AccountMembership, error)
}

type AccountMembership struct {
ID uuid.UUID `json:"id"`
ActorID uuid.UUID `json:"actor_id"`
UserID uuid.UUID `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Handle string `json:"handle"`
Email string `json:"email"`
AccountRoleName string `json:"account_role_name"`
AccountRoleID uuid.UUID `json:"account_role_id"`
LastLogin *time.Time `json:"last_login"`
}

// AccountMembershipFilter defines the search filter payload
// when searching for workspace roles by name.
// example request payload:
// {"account_memberships": {"email": {"any_": ["test"]}}}.
type AccountMembershipFilter struct {
AccountMemberships struct {
Email struct {
Any []string `json:"any_"`
} `json:"email,omitempty"`
} `json:"account_memberships"`
}
1 change: 1 addition & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "github.com/google/uuid"
// PrefectClient returns clients for different aspects of our API.
type PrefectClient interface {
Accounts() (AccountsClient, error)
AccountMemberships(accountID uuid.UUID) (AccountMembershipsClient, error)
AccountRoles(accountID uuid.UUID) (AccountRolesClient, error)
Workspaces(accountID uuid.UUID) (WorkspacesClient, error)
WorkspaceAccess(accountID uuid.UUID, workspaceID uuid.UUID) (WorkspaceAccessClient, error)
Expand Down
File renamed without changes.
73 changes: 73 additions & 0 deletions internal/client/account_memberships.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/google/uuid"
"github.com/prefecthq/terraform-provider-prefect/internal/api"
)

var _ = api.AccountMembershipsClient(&AccountMembershipsClient{})

type AccountMembershipsClient struct {
hc *http.Client
apiKey string
routePrefix string
}

// AccountMemberships is a factory that initializes and returns a AccountMembershipsClient.
//
//nolint:ireturn // required to support PrefectClient mocking
func (c *Client) AccountMemberships(accountID uuid.UUID) (api.AccountMembershipsClient, error) {
if accountID == uuid.Nil {
accountID = c.defaultAccountID
}

return &AccountMembershipsClient{
hc: c.hc,
apiKey: c.apiKey,
routePrefix: fmt.Sprintf("%s/accounts/%s/account_memberships", c.endpoint, accountID.String()),
}, nil
}

// List returns a list of account memberships, based on the provided filter.
func (c *AccountMembershipsClient) List(ctx context.Context, emails []string) ([]*api.AccountMembership, error) {
var buf bytes.Buffer
filterQuery := api.AccountMembershipFilter{}
filterQuery.AccountMemberships.Email.Any = emails

if err := json.NewEncoder(&buf).Encode(&filterQuery); err != nil {
return nil, fmt.Errorf("failed to encode filter payload data: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/filter", c.routePrefix), &buf)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}

setDefaultHeaders(req, c.apiKey)

resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("http error: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
errorBody, _ := io.ReadAll(resp.Body)

return nil, fmt.Errorf("status code %s, error=%s", resp.Status, errorBody)
}

var accountMemberships []*api.AccountMembership
if err := json.NewDecoder(resp.Body).Decode(&accountMemberships); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return accountMemberships, nil
}
176 changes: 176 additions & 0 deletions internal/provider/datasources/account_member.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package datasources

import (
"context"
"fmt"

"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/prefecthq/terraform-provider-prefect/internal/api"
"github.com/prefecthq/terraform-provider-prefect/internal/provider/customtypes"
"github.com/prefecthq/terraform-provider-prefect/internal/provider/helpers"
)

// Ensure the implementation satisfies the expected interfaces.
var _ datasource.DataSource = &AccountMemberDataSource{}
var _ datasource.DataSourceWithConfigure = &AccountMemberDataSource{}

type AccountMemberDataSource struct {
client api.PrefectClient
}

type AccountMemberDataSourceModel struct {
ID customtypes.UUIDValue `tfsdk:"id"`
ActorID customtypes.UUIDValue `tfsdk:"actor_id"`
UserID customtypes.UUIDValue `tfsdk:"user_id"`
FirstName types.String `tfsdk:"first_name"`
LastName types.String `tfsdk:"last_name"`
Handle types.String `tfsdk:"handle"`
Email types.String `tfsdk:"email"`
AccountRoleID customtypes.UUIDValue `tfsdk:"account_role_id"`
AccountRoleName types.String `tfsdk:"account_role_name"`

AccountID customtypes.UUIDValue `tfsdk:"account_id"`
}

// NewAccountMemberDataSource returns a new AccountMemberDataSource.
//
//nolint:ireturn // required by Terraform API
func NewAccountMemberDataSource() datasource.DataSource {
return &AccountMemberDataSource{}
}

// Metadata returns the data source type name.
func (d *AccountMemberDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_account_member"
}

// Schema defines the schema for the data source.
func (d *AccountMemberDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Data Source representing a Prefect Account Member",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
CustomType: customtypes.UUIDType{},
Description: "Account Member UUID",
},
"actor_id": schema.StringAttribute{
Computed: true,
CustomType: customtypes.UUIDType{},
Description: "Actor ID UUID",
},
"user_id": schema.StringAttribute{
Computed: true,
CustomType: customtypes.UUIDType{},
Description: "User ID UUID",
},
"first_name": schema.StringAttribute{
Computed: true,
Description: "Member First Name",
},
"last_name": schema.StringAttribute{
Computed: true,
Description: "Member Last Name",
},
"handle": schema.StringAttribute{
Computed: true,
Description: "Member Handle",
},
"email": schema.StringAttribute{
Required: true,
Description: "Member Email",
},
"account_role_id": schema.StringAttribute{
Computed: true,
CustomType: customtypes.UUIDType{},
Description: "Account Role ID UUID",
},
"account_role_name": schema.StringAttribute{
Computed: true,
Description: "Member Account Role Name",
},
"account_id": schema.StringAttribute{
Optional: true,
CustomType: customtypes.UUIDType{},
Description: "Account UUID where the Account Member resides",
},
},
}
}

// Configure adds the provider-configured client to the data source.
func (d *AccountMemberDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
if req.ProviderData == nil {
return
}

client, ok := req.ProviderData.(api.PrefectClient)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Data Source Configure Type",
fmt.Sprintf("Expected api.PrefectClient, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)

return
}

d.client = client
}

// Read refreshes the Terraform state with the latest data.
func (d *AccountMemberDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var config AccountMemberDataSourceModel

// Populate the model from data source configuration and emit diagnostics on error
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}

client, err := d.client.AccountMemberships(config.AccountID.ValueUUID())
if err != nil {
resp.Diagnostics.Append(helpers.CreateClientErrorDiagnostic("Account Memberships", err))

return
}

// Fetch an existing Account Member by email
// Here, we'd expect only 1 Member (or none) to be returned
// as we are querying a single Member email, not a list of emails
// workspaceRoles, err := client.List(ctx, []string{model.Name.ValueString()})
accountMembers, err := client.List(ctx, []string{config.Email.ValueString()})
if err != nil {
resp.Diagnostics.AddError(
"Error refreshing Account Member state",
fmt.Sprintf("Could not search for Account Members, unexpected error: %s", err.Error()),
)
}

if len(accountMembers) != 1 {
resp.Diagnostics.AddError(
"Could not find Account Member",
fmt.Sprintf("Could not find Account Member with email %s", config.Email.ValueString()),
)

return
}

fetchedAccountMember := accountMembers[0]

config.ID = customtypes.NewUUIDValue(fetchedAccountMember.ID)
config.ActorID = customtypes.NewUUIDValue(fetchedAccountMember.ActorID)
config.UserID = customtypes.NewUUIDValue(fetchedAccountMember.UserID)
config.FirstName = types.StringValue(fetchedAccountMember.FirstName)
config.LastName = types.StringValue(fetchedAccountMember.LastName)
config.Handle = types.StringValue(fetchedAccountMember.Handle)
config.Email = types.StringValue(fetchedAccountMember.Email)
config.AccountRoleID = customtypes.NewUUIDValue(fetchedAccountMember.AccountRoleID)
config.AccountRoleName = types.StringValue(fetchedAccountMember.AccountRoleName)

resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
}
43 changes: 43 additions & 0 deletions internal/provider/datasources/account_member_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package datasources_test

import (
"fmt"
"testing"

"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/prefecthq/terraform-provider-prefect/internal/testutils"
)

func fixtureAccAccountMember(email string) string {
return fmt.Sprintf(`
data "prefect_account_member" "member" {
email = "%s"
}
`, email)
}

//nolint:paralleltest // we use the resource.ParallelTest helper instead
func TestAccDatasource_account_member(t *testing.T) {
dataSourceName := "data.prefect_account_member.member"

resource.ParallelTest(t, resource.TestCase{
ProtoV6ProviderFactories: testutils.TestAccProtoV6ProviderFactories,
PreCheck: func() { testutils.AccTestPreCheck(t) },
Steps: []resource.TestStep{
{
Config: fixtureAccAccountMember("[email protected]"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(dataSourceName, "email", "[email protected]"),
resource.TestCheckResourceAttrSet(dataSourceName, "id"),
resource.TestCheckResourceAttrSet(dataSourceName, "account_role_id"),
resource.TestCheckResourceAttrSet(dataSourceName, "account_role_name"),
resource.TestCheckResourceAttrSet(dataSourceName, "actor_id"),
resource.TestCheckResourceAttrSet(dataSourceName, "first_name"),
resource.TestCheckResourceAttrSet(dataSourceName, "last_name"),
resource.TestCheckResourceAttrSet(dataSourceName, "handle"),
resource.TestCheckResourceAttrSet(dataSourceName, "user_id"),
),
},
},
})
}
4 changes: 2 additions & 2 deletions internal/provider/datasources/workspace_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ var workspaceRoleAttributes = map[string]schema.Attribute{
Description: "Name of the Workspace Role",
},
"description": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "Description of the Workspace Role",
},
"scopes": schema.ListAttribute{
Expand All @@ -80,7 +80,7 @@ var workspaceRoleAttributes = map[string]schema.Attribute{
Description: "Account UUID where Workspace Role resides",
},
"inherited_role_id": schema.StringAttribute{
Optional: true,
Computed: true,
CustomType: customtypes.UUIDType{},
Description: "Workspace Role UUID, whose permissions are inherited by this Workspace Role",
},
Expand Down
Loading

0 comments on commit 9510152

Please sign in to comment.