From d6ce0397c5e81c91b7aa7d85c1711bd14f64f7b4 Mon Sep 17 00:00:00 2001 From: Amanda Hager Lopes de Andrade Katz Date: Tue, 19 Nov 2024 18:20:26 -0300 Subject: [PATCH] feat: add juju_access_offer resource --- docs/resources/access_offer.md | 50 ++ .../resources/juju_access_offer/import.sh | 6 + .../resources/juju_access_offer/resource.tf | 4 + internal/juju/offers.go | 66 ++- internal/provider/helpers.go | 1 + internal/provider/provider.go | 1 + internal/provider/resource_access_offer.go | 547 ++++++++++++++++++ .../provider/resource_access_offer_test.go | 214 +++++++ 8 files changed, 887 insertions(+), 2 deletions(-) create mode 100644 docs/resources/access_offer.md create mode 100644 examples/resources/juju_access_offer/import.sh create mode 100644 examples/resources/juju_access_offer/resource.tf create mode 100644 internal/provider/resource_access_offer.go create mode 100644 internal/provider/resource_access_offer_test.go diff --git a/docs/resources/access_offer.md b/docs/resources/access_offer.md new file mode 100644 index 00000000..13cec545 --- /dev/null +++ b/docs/resources/access_offer.md @@ -0,0 +1,50 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "juju_access_offer Resource - terraform-provider-juju" +subcategory: "" +description: |- + A resource that represent a Juju Access Offer. Warning: Do not repeat users across different access levels. +--- + +# juju_access_offer (Resource) + +A resource that represent a Juju Access Offer. Warning: Do not repeat users across different access levels. + +## Example Usage + +```terraform +resource "juju_access_offer" "this" { + offer_url = juju_offer.my_application_offer.url + consume = [juju_user.dev.name] +} +``` + + +## Schema + +### Required + +- `offer_url` (String) The url of the offer for access management. If this is changed the resource will be deleted and a new resource will be created. + +### Optional + +- `admin` (Set of String) List of users to grant admin access. "admin" user is not allowed. +- `consume` (Set of String) List of users to grant consume access. "admin" user is not allowed. +- `read` (Set of String) List of users to grant read access. "admin" user is not allowed. + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +# Access Offers can be imported by using the Offer URL as in the juju show-offers output. +# Example: +# $juju show-offer mysql +# Store URL Access Description Endpoint Interface Role +# mycontroller admin/db.mysql admin MariaDB Server is one of the most ... mysql mysql provider +$ terraform import juju_access_offer.db admin/db.mysql +``` diff --git a/examples/resources/juju_access_offer/import.sh b/examples/resources/juju_access_offer/import.sh new file mode 100644 index 00000000..094b0170 --- /dev/null +++ b/examples/resources/juju_access_offer/import.sh @@ -0,0 +1,6 @@ +# Access Offers can be imported by using the Offer URL as in the juju show-offers output. +# Example: +# $juju show-offer mysql +# Store URL Access Description Endpoint Interface Role +# mycontroller admin/db.mysql admin MariaDB Server is one of the most ... mysql mysql provider +$ terraform import juju_access_offer.db admin/db.mysql diff --git a/examples/resources/juju_access_offer/resource.tf b/examples/resources/juju_access_offer/resource.tf new file mode 100644 index 00000000..265ee378 --- /dev/null +++ b/examples/resources/juju_access_offer/resource.tf @@ -0,0 +1,4 @@ +resource "juju_access_offer" "this" { + offer_url = juju_offer.my_application_offer.url + consume = [juju_user.dev.name] +} diff --git a/internal/juju/offers.go b/internal/juju/offers.go index b8428417..7828c03a 100644 --- a/internal/juju/offers.go +++ b/internal/juju/offers.go @@ -54,6 +54,7 @@ type ReadOfferResponse struct { ModelName string Name string OfferURL string + Users []crossmodel.OfferUserDetails } type DestroyOfferInput struct { @@ -74,12 +75,20 @@ type RemoveRemoteOfferInput struct { OfferURL string } +// GrantRevokeOfferInput represents input for granting or revoking access to an offer. +type GrantRevokeOfferInput struct { + Users []string + Access string + OfferURL string +} + func newOffersClient(sc SharedClient) *offersClient { return &offersClient{ SharedClient: sc, } } +// CreateOffer creates offer managed by the offer resource. func (c offersClient) CreateOffer(input *CreateOfferInput) (*CreateOfferResponse, []error) { var errs []error @@ -152,6 +161,7 @@ func (c offersClient) CreateOffer(input *CreateOfferInput) (*CreateOfferResponse return &resp, nil } +// ReadOffer reads offer managed by the offer resource. func (c offersClient) ReadOffer(input *ReadOfferInput) (*ReadOfferResponse, error) { conn, err := c.GetConnection(nil) if err != nil { @@ -170,6 +180,7 @@ func (c offersClient) ReadOffer(input *ReadOfferInput) (*ReadOfferResponse, erro response.ApplicationName = result.ApplicationName response.OfferURL = result.OfferURL response.Endpoint = result.Endpoints[0].Name + response.Users = result.Users //no model name is returned but it can be parsed from the resulting offer URL to ensure parity //TODO: verify if we can fetch information another way @@ -182,6 +193,7 @@ func (c offersClient) ReadOffer(input *ReadOfferInput) (*ReadOfferResponse, erro return &response, nil } +// DestroyOffer destroys offer managed by the offer resource. func (c offersClient) DestroyOffer(input *DestroyOfferInput) error { conn, err := c.GetConnection(nil) if err != nil { @@ -249,7 +261,7 @@ func parseModelFromURL(url string) (result string, success bool) { return result, true } -// This function allows the integration resource to consume the offers managed by the offer resource +// ConsumeRemoteOffer allows the integration resource to consume the offers managed by the offer resource. func (c offersClient) ConsumeRemoteOffer(input *ConsumeRemoteOfferInput) (*ConsumeRemoteOfferResponse, error) { modelConn, err := c.GetConnection(&input.ModelName) if err != nil { @@ -330,7 +342,7 @@ func (c offersClient) ConsumeRemoteOffer(input *ConsumeRemoteOfferInput) (*Consu return &response, nil } -// This function allows the integration resource to destroy the offers managed by the offer resource +// RemoveRemoteOffer allows the integration resource to destroy the offers managed by the offer resource. func (c offersClient) RemoveRemoteOffer(input *RemoveRemoteOfferInput) []error { var errors []error conn, err := c.GetConnection(&input.ModelName) @@ -390,3 +402,53 @@ func (c offersClient) RemoveRemoteOffer(input *RemoveRemoteOfferInput) []error { return nil } + +// GrantOffer adds access to an offer managed by the access offer resource. +// No action or error is returned if the access was already granted to the user. +func (c offersClient) GrantOffer(input *GrantRevokeOfferInput) error { + conn, err := c.GetConnection(nil) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + client := applicationoffers.NewClient(conn) + + for _, user := range input.Users { + err = client.GrantOffer(user, input.Access, input.OfferURL) + if err != nil { + // ignore if user was already granted + if strings.Contains(err.Error(), "user already has") { + continue + } + return err + } + } + + return nil +} + +// RevokeOffer revokes access to an offer managed by the access offer resource. +// No action or error if the access was already revoked for the user. +func (c offersClient) RevokeOffer(input *GrantRevokeOfferInput) error { + conn, err := c.GetConnection(nil) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + client := applicationoffers.NewClient(conn) + + for _, user := range input.Users { + err = client.RevokeOffer(user, input.Access, input.OfferURL) + if err != nil { + // ignore if user was already revoked + if strings.Contains(err.Error(), "not found") { + continue + } + return err + } + } + + return nil +} diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 0ac70790..e95df9e9 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -23,6 +23,7 @@ const ( LogResourceApplication = "resource-application" LogResourceAccessModel = "resource-access-model" + LogResourceAccessOffer = "resource-access-offer" LogResourceCredential = "resource-credential" LogResourceKubernetesCloud = "resource-kubernetes-cloud" LogResourceMachine = "resource-machine" diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4507dd78..2b5ecc44 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -361,6 +361,7 @@ func getJujuProviderModel(ctx context.Context, req provider.ConfigureRequest) (j func (p *jujuProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ func() resource.Resource { return NewAccessModelResource() }, + func() resource.Resource { return NewAccessOfferResource() }, func() resource.Resource { return NewApplicationResource() }, func() resource.Resource { return NewCredentialResource() }, func() resource.Resource { return NewIntegrationResource() }, diff --git a/internal/provider/resource_access_offer.go b/internal/provider/resource_access_offer.go new file mode 100644 index 00000000..350d29af --- /dev/null +++ b/internal/provider/resource_access_offer.go @@ -0,0 +1,547 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/juju/juju/core/crossmodel" + "github.com/juju/juju/core/permission" + "github.com/juju/names/v5" + "github.com/juju/terraform-provider-juju/internal/juju" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &accessOfferResource{} +var _ resource.ResourceWithConfigure = &accessOfferResource{} +var _ resource.ResourceWithImportState = &accessOfferResource{} +var _ resource.ResourceWithConfigValidators = &accessOfferResource{} + +// NewAccessOfferResource returns a new instance of the Access Offer resource. +func NewAccessOfferResource() resource.Resource { + return &accessOfferResource{} +} + +type accessOfferResource struct { + client *juju.Client + + // subCtx is the context created with the new tflog subsystem for applications. + subCtx context.Context +} + +type accessOfferResourceOffer struct { + OfferURL types.String `tfsdk:"offer_url"` + AdminUsers types.Set `tfsdk:"admin"` + ConsumeUsers types.Set `tfsdk:"consume"` + ReadUsers types.Set `tfsdk:"read"` + + // ID required by the testing framework + ID types.String `tfsdk:"id"` +} + +// Metadata returns metadata about the access offer resource. +func (a *accessOfferResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_access_offer" +} + +// Schema defines the schema for the access offer resource. +func (a *accessOfferResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A resource that represent a Juju Access Offer. Warning: Do not repeat users across different access levels.", + Attributes: map[string]schema.Attribute{ + string(permission.AdminAccess): schema.SetAttribute{ + Description: "List of users to grant admin access. \"admin\" user is not allowed.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(ValidatorMatchString(names.IsValidUser, "user must be a valid Juju username")), + }, + }, + string(permission.ConsumeAccess): schema.SetAttribute{ + Description: "List of users to grant consume access. \"admin\" user is not allowed.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(ValidatorMatchString(names.IsValidUser, "user must be a valid Juju username")), + }, + }, + string(permission.ReadAccess): schema.SetAttribute{ + Description: "List of users to grant read access. \"admin\" user is not allowed.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(ValidatorMatchString(names.IsValidUser, "user must be a valid Juju username")), + }, + }, + // ID required for imports + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "offer_url": schema.StringAttribute{ + Description: "The url of the offer for access management. If this is changed the resource will be deleted and a new resource will be created.", + Required: true, + Validators: []validator.String{ + ValidatorMatchString(func(s string) bool { + _, err := crossmodel.ParseOfferURL(s) + return err == nil + }, "offer_url must be a valid offer string."), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +// Create attempts to grant access to the offer. +func (a *accessOfferResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Check first if the client is configured + if a.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "access offer", "create") + return + } + var plan accessOfferResourceOffer + + // Read Terraform configuration from the request into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Get the users to grant admin + var adminUsers []string + if !plan.AdminUsers.IsNull() { + resp.Diagnostics.Append(plan.AdminUsers.ElementsAs(ctx, &adminUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Get the users to grant consume + var consumeUsers []string + if !plan.ConsumeUsers.IsNull() { + resp.Diagnostics.Append(plan.ConsumeUsers.ElementsAs(ctx, &consumeUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Get the users to grant read + var readUsers []string + if !plan.ReadUsers.IsNull() { + resp.Diagnostics.Append(plan.ReadUsers.ElementsAs(ctx, &readUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // validate if there are overlaps or admin user + // validation is done here considering dynamic (juju_user resource) and static values for users + err := validateNoOverlapsNoAdmin(adminUsers, consumeUsers, readUsers) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create access offer resource, got error: %s", err)) + return + } + + // Call Offers.GrantOffer + totalUsers := make(map[permission.Access][]string) + totalUsers[permission.ConsumeAccess] = consumeUsers + totalUsers[permission.ReadAccess] = readUsers + totalUsers[permission.AdminAccess] = adminUsers + + for access, users := range totalUsers { + err := a.client.Offers.GrantOffer(&juju.GrantRevokeOfferInput{ + Users: users, + Access: string(access), + OfferURL: plan.OfferURL.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create access offer resource, got error: %s", err)) + return + } + } + + // Set ID as the offer URL + plan.ID = plan.OfferURL + + // Set the plan onto the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Read reads users and permissions granted to the offer +func (a *accessOfferResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Check first if the client is configured + if a.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "access offer", "read") + return + } + var state accessOfferResourceOffer + + // Get the Terraform state + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Get information from ID + offerURL := state.ID.ValueString() + + // Get user/access info from Offer + response, err := a.client.Offers.ReadOffer(&juju.ReadOfferInput{ + OfferURL: offerURL, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read offer %s, got error: %s", offerURL, err)) + return + } + a.trace(fmt.Sprintf("read juju offer %q", offerURL)) + + // Create the map + users := make(map[permission.Access][]string) + users[permission.ConsumeAccess] = []string{} + users[permission.ReadAccess] = []string{} + users[permission.AdminAccess] = []string{} + for _, offerUserDetail := range response.Users { + if offerUserDetail.UserName == "everyone@external" || offerUserDetail.UserName == "admin" { + continue + } + + if _, ok := users[offerUserDetail.Access]; !ok { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("User %s has unexpected access %s", offerUserDetail.UserName, offerUserDetail.Access)) + return + } + + users[offerUserDetail.Access] = append(users[offerUserDetail.Access], offerUserDetail.UserName) + } + a.trace(fmt.Sprintf("read juju offer response %q", response)) + // Save admin users to state + if len(users[permission.AdminAccess]) > 0 { + adminUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, users[permission.AdminAccess]) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + state.AdminUsers = adminUsersSet + } + // Save consume users to state + if len(users[permission.ConsumeAccess]) > 0 { + consumeUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, users[permission.ConsumeAccess]) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + state.ConsumeUsers = consumeUsersSet + } + // Save read users to state + if len(users[permission.ReadAccess]) > 0 { + readUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, users[permission.ReadAccess]) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + state.ReadUsers = readUsersSet + } + // Set the plan onto the Terraform state + state.OfferURL = types.StringValue(offerURL) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update attempts to update the access to the offer. +func (a *accessOfferResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Check first if the client is configured + if a.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "access offer", "update") + return + } + var plan, state accessOfferResourceOffer + + // Read Terraform configuration from the request into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Get the users to grant admin + var adminUsers []string + if !plan.AdminUsers.IsNull() { + resp.Diagnostics.Append(plan.AdminUsers.ElementsAs(ctx, &adminUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Get the users to grant consume + var consumeUsers []string + if !plan.ConsumeUsers.IsNull() { + resp.Diagnostics.Append(plan.ConsumeUsers.ElementsAs(ctx, &consumeUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Get the users to grant read + var readUsers []string + if !plan.ReadUsers.IsNull() { + resp.Diagnostics.Append(plan.ReadUsers.ElementsAs(ctx, &readUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // validate if there are overlaps or admin user + // validation is done here considering dynamic (juju_user resource) and static values for users + err := validateNoOverlapsNoAdmin(adminUsers, consumeUsers, readUsers) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update access offer resource, got error: %s", err)) + return + } + + // Get users from state + var readStateUsers []string + if !state.ReadUsers.IsNull() { + resp.Diagnostics.Append(state.ReadUsers.ElementsAs(ctx, &readStateUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + var consumeStateUsers []string + if !state.ConsumeUsers.IsNull() { + resp.Diagnostics.Append(state.ConsumeUsers.ElementsAs(ctx, &consumeStateUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + var adminStateUsers []string + if !state.AdminUsers.IsNull() { + resp.Diagnostics.Append(state.AdminUsers.ElementsAs(ctx, &adminStateUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Revoke read (remove access) of all state users + totalStateUsers := append(adminStateUsers, consumeStateUsers...) + totalStateUsers = append(totalStateUsers, readStateUsers...) + if len(totalStateUsers) > 0 { + err := a.client.Offers.RevokeOffer(&juju.GrantRevokeOfferInput{ + Users: totalStateUsers, + Access: string(permission.ReadAccess), + OfferURL: plan.OfferURL.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update access offer resource, got error: %s", err)) + return + } + } + + // grant read + err = grantPermission(plan.OfferURL.ValueString(), string(permission.ReadAccess), readUsers, a.client) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update access offer resource, got error: %s", err)) + return + } + + // grant consume + err = grantPermission(plan.OfferURL.ValueString(), string(permission.ConsumeAccess), consumeUsers, a.client) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update access offer resource, got error: %s", err)) + return + } + + // grant admin + err = grantPermission(plan.OfferURL.ValueString(), string(permission.AdminAccess), adminUsers, a.client) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update access offer resource, got error: %s", err)) + return + } + + // Save admin users to state + adminUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, adminUsers) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + plan.AdminUsers = adminUsersSet + // Save consume users to state + consumeUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, consumeUsers) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + plan.ConsumeUsers = consumeUsersSet + // Save read users to state + readUsersSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, readUsers) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + plan.ReadUsers = readUsersSet + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Delete remove access to the offer according to the resource. +func (a *accessOfferResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Check first if the client is configured + if a.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "access offer", "read") + return + } + var plan accessOfferResourceOffer + + // Get the Terraform state from the request into the plan + resp.Diagnostics.Append(req.State.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Get the users to grant admin + var adminUsers []string + if !plan.AdminUsers.IsNull() { + resp.Diagnostics.Append(plan.AdminUsers.ElementsAs(ctx, &adminUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Get the users to grant consume + var consumeUsers []string + if !plan.ConsumeUsers.IsNull() { + resp.Diagnostics.Append(plan.ConsumeUsers.ElementsAs(ctx, &consumeUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Get the users to grant read + var readUsers []string + if !plan.ReadUsers.IsNull() { + resp.Diagnostics.Append(plan.ReadUsers.ElementsAs(ctx, &readUsers, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + totalPlanUsers := append(adminUsers, consumeUsers...) + totalPlanUsers = append(totalPlanUsers, readUsers...) + + // Revoking against "read" guarantees that the entire access will be removed + // instead of only decreasing the access level. + err := a.client.Offers.RevokeOffer(&juju.GrantRevokeOfferInput{ + Users: totalPlanUsers, + Access: string(permission.ReadAccess), + OfferURL: plan.OfferURL.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to destroy access offer resource, got error: %s", err)) + return + } +} + +// Configure sets the access offer resource with provider data. +func (a *accessOfferResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*juju.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *juju.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + a.client = client + // Create the local logging subsystem here, using the TF context when creating it. + a.subCtx = tflog.NewSubsystem(ctx, LogResourceAccessOffer) +} + +// ConfigValidators sets validators for the resource. +func (a *accessOfferResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator { + // JAAS users should use juju_jaas_access_offer instead. + return []resource.ConfigValidator{ + NewAvoidJAASValidator(a.client, "juju_jaas_access_offer"), + resourcevalidator.AtLeastOneOf( + path.MatchRoot(string(permission.AdminAccess)), + path.MatchRoot(string(permission.ConsumeAccess)), + path.MatchRoot(string(permission.ReadAccess)), + ), + } +} + +// ImportState import existing resource to the state. +func (a *accessOfferResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (a *accessOfferResource) trace(msg string, additionalFields ...map[string]interface{}) { + if a.subCtx == nil { + return + } + + tflog.SubsystemTrace(a.subCtx, LogResourceAccessOffer, msg, additionalFields...) +} + +// Helpers +func validateNoOverlapsNoAdmin(admin, consume, read []string) error { + sets := map[string]struct{}{} + for _, v := range consume { + if v == "admin" { + return fmt.Errorf("user admin is not allowed") + } + sets[v] = struct{}{} + } + for _, v := range read { + if v == "admin" { + return fmt.Errorf("user admin is not allowed") + } + if _, exists := sets[v]; exists { + return fmt.Errorf("user '%s' appears in both 'consume' and 'read'", v) + } + sets[v] = struct{}{} + } + for _, v := range admin { + if v == "admin" { + return fmt.Errorf("user admin is not allowed") + } + if _, exists := sets[v]; exists { + return fmt.Errorf("user '%s' appears in multiple roles (e.g., 'consume', 'read', 'admin')", v) + } + } + + return nil +} + +func grantPermission(offerURL, permissionType string, planUsers []string, jujuClient *juju.Client) error { + err := jujuClient.Offers.GrantOffer(&juju.GrantRevokeOfferInput{ + Users: planUsers, + Access: permissionType, + OfferURL: offerURL, + }) + if err != nil { + return err + } + return nil +} diff --git a/internal/provider/resource_access_offer_test.go b/internal/provider/resource_access_offer_test.go new file mode 100644 index 00000000..737762e5 --- /dev/null +++ b/internal/provider/resource_access_offer_test.go @@ -0,0 +1,214 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + internaltesting "github.com/juju/terraform-provider-juju/internal/testing" +) + +func TestAcc_ResourceAccessOffer(t *testing.T) { + SkipJAAS(t) + AdminUserName := acctest.RandomWithPrefix("tfadminuser") + ConsumeUserName := acctest.RandomWithPrefix("tfconsumeuser") + ReadUserName := acctest.RandomWithPrefix("tfreaduser") + userPassword := acctest.RandomWithPrefix("tf-test-user") + modelName := acctest.RandomWithPrefix("tf-access-model") + offerURL := fmt.Sprintf("admin/%s.appone", modelName) + + resourceName := "juju_access_offer.access_appone_endpoint" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { // Test username overlap validation + Config: testAccResourceAccessOffer(AdminUserName, ConsumeUserName, ReadUserName, "admin", "admin", "", userPassword, modelName), + ExpectError: regexp.MustCompile("appears in.*"), + }, + { // Create the resource with user as admin + Config: testAccResourceAccessOffer(AdminUserName, ConsumeUserName, ReadUserName, "admin", "consume", "read", userPassword, modelName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "offer_url", offerURL), + resource.TestCheckTypeSetElemAttr(resourceName, "admin.*", AdminUserName), + resource.TestCheckTypeSetElemAttr(resourceName, "consume.*", ConsumeUserName), + resource.TestCheckTypeSetElemAttr(resourceName, "read.*", ReadUserName), + ), + }, + { // Change Admin to Consume and Consume to Admin + Config: testAccResourceAccessOffer(AdminUserName, ConsumeUserName, ReadUserName, "consume", "admin", "read", userPassword, modelName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "offer_url", offerURL), + resource.TestCheckTypeSetElemAttr(resourceName, "admin.*", ConsumeUserName), + resource.TestCheckTypeSetElemAttr(resourceName, "consume.*", AdminUserName), + resource.TestCheckTypeSetElemAttr(resourceName, "read.*", ReadUserName), + ), + }, + { // Remove user from read permission + Config: testAccResourceAccessOffer(AdminUserName, ConsumeUserName, ReadUserName, "consume", "admin", "", userPassword, modelName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "offer_url", offerURL), + resource.TestCheckTypeSetElemAttr(resourceName, "admin.*", ConsumeUserName), + resource.TestCheckTypeSetElemAttr(resourceName, "consume.*", AdminUserName), + resource.TestCheckNoResourceAttr(resourceName, "read.*"), + ), + }, + { // Destroy the resource and validate it can be imported correctly + Destroy: true, + ImportStateVerify: true, + ImportState: true, + ImportStateId: offerURL, + ResourceName: resourceName, + }, + }, + }) +} + +func TestAcc_ResourceAccessOffer_ErrorWhenUsedWithJAAS(t *testing.T) { + OnlyTestAgainstJAAS(t) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceAccessOfferFixedUser(), + ExpectError: regexp.MustCompile("This resource is not supported with JAAS"), + }, + }, + }) +} + +func TestAcc_ResourceAccessOffer_ErrorWhenUsedWithAdmin(t *testing.T) { + SkipJAAS(t) + + modelNameAdminTest := acctest.RandomWithPrefix("tf-access-admin-model") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { // Test username admin validation + Config: testAccResourceAccessOfferAdminUser(modelNameAdminTest), + ExpectError: regexp.MustCompile("user admin is not allowed.*"), + }, + }, + }) +} + +func testAccResourceAccessOfferFixedUser() string { + return ` +resource "juju_access_offer" "test" { + offer_url = "admin/db.mysql" + admin = ["bob"] +}` +} + +func testAccResourceAccessOfferAdminUser(modelName string) string { + return internaltesting.GetStringFromTemplateWithData("testAccResourceAccessOfferAdminUser", ` +resource "juju_model" "{{.ModelName}}" { +name = "{{.ModelName}}" +} + +resource "juju_application" "appone" { + name = "appone" + model = juju_model.{{.ModelName}}.name + + charm { + name = "juju-qa-dummy-source" + base = "ubuntu@22.04" + } +} + +resource "juju_offer" "appone_endpoint" { + model = juju_model.{{.ModelName}}.name + application_name = juju_application.appone.name + endpoint = "sink" +} + +resource "juju_access_offer" "test" { + offer_url = juju_offer.appone_endpoint.url + admin = ["admin"] +}`, internaltesting.TemplateData{ + "ModelName": modelName}) +} + +func testAccResourceAccessOffer(AdminUserName, ConsumeUserName, ReadUserName, OfferAdmin, OfferConsume, OfferRead, userPassword, modelName string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceAccessOffer", + ` +resource "juju_model" "{{.ModelName}}" { +name = "{{.ModelName}}" +} + +{{- if ne .AdminUserName "" }} +resource "juju_user" "admin_operator" { + name = "{{.AdminUserName}}" + password = "{{.UserPassword}}" +} +{{- end }} + +{{- if ne .ConsumeUserName "" }} +resource "juju_user" "consume_operator" { + name = "{{.ConsumeUserName}}" + password = "{{.UserPassword}}" +} +{{- end }} + +{{- if ne .ReadUserName "" }} +resource "juju_user" "read_operator" { + name = "{{.ReadUserName}}" + password = "{{.UserPassword}}" +} +{{- end }} + +resource "juju_application" "appone" { + name = "appone" + model = juju_model.{{.ModelName}}.name + + charm { + name = "juju-qa-dummy-source" + base = "ubuntu@22.04" + } +} + +resource "juju_offer" "appone_endpoint" { + model = juju_model.{{.ModelName}}.name + application_name = juju_application.appone.name + endpoint = "sink" +} + +resource "juju_access_offer" "access_appone_endpoint" { + offer_url = juju_offer.appone_endpoint.url + {{- if ne .OfferAdmin "" }} + admin = [ + juju_user.{{.OfferAdmin}}_operator.name, + ] + {{- end }} + {{- if ne .OfferConsume "" }} + consume = [ + juju_user.{{.OfferConsume}}_operator.name, + ] + {{- end }} + {{- if ne .OfferRead "" }} + read = [ + juju_user.{{.OfferRead}}_operator.name, + ] + {{- end }} +} +`, internaltesting.TemplateData{ + "ModelName": modelName, + "AdminUserName": AdminUserName, + "ConsumeUserName": ConsumeUserName, + "ReadUserName": ReadUserName, + "OfferAdmin": OfferAdmin, + "OfferConsume": OfferConsume, + "OfferRead": OfferRead, + "UserPassword": userPassword, + }) +}