diff --git a/docs/data-sources/account.md b/docs/data-sources/account.md index 32f554fd..3a539b5b 100644 --- a/docs/data-sources/account.md +++ b/docs/data-sources/account.md @@ -38,6 +38,7 @@ data "prefect_account" "my_organization" {} - `billing_email` (String) Billing email to apply to the account's Stripe customer - `created` (String) Timestamp of when the resource was created (RFC3339) +- `domain_names` (List of String) The list of domain names for enabling SSO in Prefect Cloud. - `handle` (String) Unique handle of the account - `link` (String) An optional for an external url associated with the account, e.g. https://prefect.io/ - `location` (String) An optional physical location for the account, e.g. Washington, D.C. diff --git a/docs/resources/account.md b/docs/resources/account.md index 2e0a0772..a2cf707b 100644 --- a/docs/resources/account.md +++ b/docs/resources/account.md @@ -39,6 +39,7 @@ resource "prefect_account" "example" { ### Optional - `billing_email` (String) Billing email to apply to the account's Stripe customer +- `domain_names` (List of String) The list of domain names for enabling SSO in Prefect Cloud. - `link` (String) An optional for an external url associated with the account, e.g. https://prefect.io/ - `location` (String) An optional physical location for the account, e.g. Washington, D.C. - `settings` (Attributes) Group of settings related to accounts (see [below for nested schema](#nestedatt--settings)) diff --git a/internal/api/accounts.go b/internal/api/accounts.go index 97c20aa3..00edda80 100644 --- a/internal/api/accounts.go +++ b/internal/api/accounts.go @@ -6,9 +6,11 @@ import ( // AccountsClient is a client for working with accounts. type AccountsClient interface { - Get(ctx context.Context) (*AccountResponse, error) + Get(ctx context.Context) (*Account, error) + GetDomains(ctx context.Context) (*AccountDomainsUpdate, error) Update(ctx context.Context, data AccountUpdate) error UpdateSettings(ctx context.Context, data AccountSettingsUpdate) error + UpdateDomains(ctx context.Context, data AccountDomainsUpdate) error Delete(ctx context.Context) error } @@ -22,37 +24,25 @@ type AccountSettings struct { // Account is a representation of an account. type Account struct { BaseModel - Name string `json:"name"` - Handle string `json:"handle"` - Location *string `json:"location"` - Link *string `json:"link"` - ImageLocation *string `json:"image_location"` - StripeCustomerID *string `json:"stripe_customer_id"` - WorkOSDirectoryIDs []string `json:"workos_directory_ids"` - WorkOSOrganizationID *string `json:"workos_organization_id"` - WorkOSConnectionIDs []string `json:"workos_connection_ids"` - AuthExpirationSeconds *int64 `json:"auth_expiration_seconds"` - Settings AccountSettings `json:"settings"` -} + AccountUpdate + + Settings AccountSettings `json:"settings"` + Domains []string `json:"domain_names"` -// AccountResponse is the data about an account returned by the Accounts API. -type AccountResponse struct { - Account + // Read-only fields + ImageLocation *string `json:"image_location"` + SSOState string `json:"sso_state"` + Features []string `json:"features"` PlanType string `json:"plan_type"` - SelfServe bool `json:"self_serve"` RunRetentionDays int64 `json:"run_retention_days"` AuditLogRetentionDays int64 `json:"audit_log_retention_days"` AutomationsLimit int64 `json:"automations_limit"` - SCIMState string `json:"scim_state"` - SSOState string `json:"sso_state"` - BillingEmail *string `json:"billing_email"` - Features []string `json:"features"` } // AccountUpdate is the data sent when updating an account. type AccountUpdate struct { - Name *string `json:"name"` - Handle *string `json:"handle"` + Name string `json:"name"` + Handle string `json:"handle"` Location *string `json:"location"` Link *string `json:"link"` AuthExpirationSeconds *int64 `json:"auth_expiration_seconds"` @@ -63,3 +53,8 @@ type AccountUpdate struct { type AccountSettingsUpdate struct { AccountSettings `json:"settings"` } + +// AccountDomainsUpdate is the data sent when updating an account's domain names. +type AccountDomainsUpdate struct { + DomainNames []string `json:"domain_names,omitempty"` +} diff --git a/internal/client/accounts.go b/internal/client/accounts.go index c30787bf..62315b7f 100644 --- a/internal/client/accounts.go +++ b/internal/client/accounts.go @@ -40,7 +40,7 @@ func (c *Client) Accounts(accountID uuid.UUID) (api.AccountsClient, error) { } // Get returns details for an account by ID. -func (c *AccountsClient) Get(ctx context.Context) (*api.AccountResponse, error) { +func (c *AccountsClient) Get(ctx context.Context) (*api.Account, error) { cfg := requestConfig{ method: http.MethodGet, url: c.routePrefix, @@ -50,7 +50,7 @@ func (c *AccountsClient) Get(ctx context.Context) (*api.AccountResponse, error) successCodes: successCodesStatusOK, } - var account api.AccountResponse + var account api.Account if err := requestWithDecodeResponse(ctx, c.hc, cfg, &account); err != nil { return nil, fmt.Errorf("failed to get account: %w", err) } @@ -58,6 +58,25 @@ func (c *AccountsClient) Get(ctx context.Context) (*api.AccountResponse, error) return &account, nil } +// GetDomains returns domain names for an account by ID. +func (c *AccountsClient) GetDomains(ctx context.Context) (*api.AccountDomainsUpdate, error) { + cfg := requestConfig{ + method: http.MethodGet, + url: c.routePrefix + "domains", + body: http.NoBody, + apiKey: c.apiKey, + basicAuthKey: c.basicAuthKey, + successCodes: successCodesStatusOK, + } + + var accountDomains api.AccountDomainsUpdate + if err := requestWithDecodeResponse(ctx, c.hc, cfg, &accountDomains.DomainNames); err != nil { + return nil, fmt.Errorf("failed to get account domains: %w", err) + } + + return &accountDomains, nil +} + // Update modifies an existing account by ID. func (c *AccountsClient) Update(ctx context.Context, data api.AccountUpdate) error { cfg := requestConfig{ @@ -98,6 +117,26 @@ func (c *AccountsClient) UpdateSettings(ctx context.Context, data api.AccountSet return nil } +// UpdateDomains modifies an existing account's domain names. +func (c *AccountsClient) UpdateDomains(ctx context.Context, data api.AccountDomainsUpdate) error { + cfg := requestConfig{ + method: http.MethodPatch, + url: c.routePrefix + "domains", + body: data.DomainNames, + apiKey: c.apiKey, + basicAuthKey: c.basicAuthKey, + successCodes: successCodesStatusNoContent, + } + + resp, err := request(ctx, c.hc, cfg) + if err != nil { + return fmt.Errorf("failed to update account domains: %w", err) + } + defer resp.Body.Close() + + return nil +} + // Delete removes an account by ID. func (c *AccountsClient) Delete(ctx context.Context) error { cfg := requestConfig{ diff --git a/internal/provider/datasources/account.go b/internal/provider/datasources/account.go index d857348a..3aa98826 100644 --- a/internal/provider/datasources/account.go +++ b/internal/provider/datasources/account.go @@ -30,6 +30,7 @@ type AccountDataSourceModel struct { Link types.String `tfsdk:"link"` Settings types.Object `tfsdk:"settings"` BillingEmail types.String `tfsdk:"billing_email"` + DomainNames types.List `tfsdk:"domain_names"` } // NewAccountDataSource returns a new AccountDataSource. @@ -126,6 +127,12 @@ Use this data source to obtain account-level attributes Computed: true, Description: "Billing email to apply to the account's Stripe customer", }, + "domain_names": schema.ListAttribute{ + Description: "The list of domain names for enabling SSO in Prefect Cloud.", + ElementType: types.StringType, + Optional: false, + Computed: true, + }, }, } } @@ -158,6 +165,12 @@ func (d *AccountDataSource) Read(ctx context.Context, req datasource.ReadRequest return } + accountDomains, err := client.GetDomains(ctx) + if err != nil { + resp.Diagnostics.Append(helpers.ResourceClientErrorDiagnostic("Account domains", "get", err)) + + return + } model.ID = customtypes.NewUUIDValue(account.ID) model.Created = customtypes.NewTimestampPointerValue(account.Created) model.Updated = customtypes.NewTimestampPointerValue(account.Updated) @@ -181,6 +194,13 @@ func (d *AccountDataSource) Read(ctx context.Context, req datasource.ReadRequest model.Settings = settingsObject + domainNames, diags := types.ListValueFrom(ctx, types.StringType, accountDomains.DomainNames) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + model.DomainNames = domainNames + model.BillingEmail = types.StringPointerValue(account.BillingEmail) model.Handle = types.StringValue(account.Handle) model.Link = types.StringPointerValue(account.Link) diff --git a/internal/provider/resources/account.go b/internal/provider/resources/account.go index 6c93f486..26c1bd90 100644 --- a/internal/provider/resources/account.go +++ b/internal/provider/resources/account.go @@ -40,6 +40,7 @@ type AccountResourceModel struct { Link types.String `tfsdk:"link"` Settings types.Object `tfsdk:"settings"` BillingEmail types.String `tfsdk:"billing_email"` + DomainNames types.List `tfsdk:"domain_names"` } // NewAccountResource returns a new AccountResource. @@ -146,13 +147,18 @@ func (r *AccountResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: "Billing email to apply to the account's Stripe customer", Optional: true, }, + "domain_names": schema.ListAttribute{ + Description: "The list of domain names for enabling SSO in Prefect Cloud.", + ElementType: types.StringType, + Optional: true, + }, }, } } // copyAccountToModel maps an API response to a model that is saved in Terraform state. // A model can be a Terraform Plan, State, or Config object. -func copyAccountToModel(_ context.Context, account *api.AccountResponse, tfModel *AccountResourceModel) diag.Diagnostics { +func copyAccountToModel(_ context.Context, account *api.Account, tfModel *AccountResourceModel) diag.Diagnostics { tfModel.ID = types.StringValue(account.ID.String()) tfModel.Created = customtypes.NewTimestampPointerValue(account.Created) tfModel.Updated = customtypes.NewTimestampPointerValue(account.Updated) @@ -181,6 +187,18 @@ func copyAccountToModel(_ context.Context, account *api.AccountResponse, tfModel return diags } +// copyAccountDomainsToModel maps an API response to a model that is saved in Terraform state. +// A model can be a Terraform Plan, State, or Config object. +func copyAccountDomainsToModel(ctx context.Context, accountDomains *api.AccountDomainsUpdate, tfModel *AccountResourceModel) diag.Diagnostics { + domainNames, diags := types.ListValueFrom(ctx, types.StringType, accountDomains.DomainNames) + if diags.HasError() { + return diags + } + tfModel.DomainNames = domainNames + + return nil +} + func (r *AccountResource) Create(_ context.Context, _ resource.CreateRequest, resp *resource.CreateResponse) { resp.Diagnostics.AddError("Cannot create account", "Account is an import-only resource and cannot be created by Terraform.") } @@ -219,6 +237,18 @@ func (r *AccountResource) Read(ctx context.Context, req resource.ReadRequest, re return } + accountDomains, err := client.GetDomains(ctx) + if err != nil { + resp.Diagnostics.Append(helpers.ResourceClientErrorDiagnostic("Account domains", "get", err)) + + return + } + + resp.Diagnostics.Append(copyAccountDomainsToModel(ctx, accountDomains, &state)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) if resp.Diagnostics.HasError() { return @@ -247,8 +277,8 @@ func (r *AccountResource) Update(ctx context.Context, req resource.UpdateRequest } err = client.Update(ctx, api.AccountUpdate{ - Name: plan.Name.ValueStringPointer(), - Handle: plan.Handle.ValueStringPointer(), + Name: plan.Name.ValueString(), + Handle: plan.Handle.ValueString(), Location: plan.Location.ValueStringPointer(), Link: plan.Link.ValueStringPointer(), BillingEmail: plan.BillingEmail.ValueStringPointer(), @@ -278,6 +308,21 @@ func (r *AccountResource) Update(ctx context.Context, req resource.UpdateRequest } } + // If domains have changed, we need to create a separate request to update them. + if !plan.DomainNames.Equal(state.DomainNames) { + var domainNames []string + resp.Diagnostics.Append(plan.DomainNames.ElementsAs(ctx, &domainNames, false)...) + + err = client.UpdateDomains(ctx, api.AccountDomainsUpdate{ + DomainNames: domainNames, + }) + if err != nil { + resp.Diagnostics.Append(helpers.ResourceClientErrorDiagnostic("Account domains", "update", err)) + + return + } + } + account, err := client.Get(ctx) if err != nil { resp.Diagnostics.Append(helpers.ResourceClientErrorDiagnostic("Account", "get", err)) diff --git a/internal/provider/resources/service_account.go b/internal/provider/resources/service_account.go index b9b3ff80..ab4e2903 100644 --- a/internal/provider/resources/service_account.go +++ b/internal/provider/resources/service_account.go @@ -184,9 +184,9 @@ func (r *ServiceAccountResource) Schema(_ context.Context, _ resource.SchemaRequ } } -// copyServiceAccountResponseToModel maps an API response to a model that is saved in Terraform state. +// copyServiceAccountToModel maps an API response to a model that is saved in Terraform state. // A model can be a Terraform Plan, State, or Config object. -func copyServiceAccountResponseToModel(serviceAccount *api.ServiceAccount, tfModel *ServiceAccountResourceModel) { +func copyServiceAccountToModel(serviceAccount *api.ServiceAccount, tfModel *ServiceAccountResourceModel) { // NOTE: the API Key is attached to the resource model outside of this helper, // as it is only returned on Create/Update operations. tfModel.ID = types.StringValue(serviceAccount.ID.String()) @@ -267,7 +267,7 @@ func (r *ServiceAccountResource) Create(ctx context.Context, req resource.Create return } - copyServiceAccountResponseToModel(serviceAccount, &plan) + copyServiceAccountToModel(serviceAccount, &plan) // The API Key is only returned on Create or when rotating the key, so we'll attach it to // the model outside of the helper function, so that we can prevent the value from being @@ -344,7 +344,7 @@ func (r *ServiceAccountResource) Read(ctx context.Context, req resource.ReadRequ return } - copyServiceAccountResponseToModel(serviceAccount, &state) + copyServiceAccountToModel(serviceAccount, &state) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) if resp.Diagnostics.HasError() { @@ -461,7 +461,7 @@ func (r *ServiceAccountResource) Update(ctx context.Context, req resource.Update } // Update the model with latest service account details (from the Get call above) - copyServiceAccountResponseToModel(serviceAccount, &plan) + copyServiceAccountToModel(serviceAccount, &plan) // The API Key is only returned on Create or when rotating the key, so we'll attach it to // the model outside of the helper function, so that we can prevent the value from being