diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 898d73d1..c22680bf 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -33,6 +33,7 @@ const ( LogResourceAccessSecret = "resource-access-secret" LogResourceJAASAccessModel = "resource-jaas-access-model" + LogResourceJAASAccessCloud = "resource-jaas-access-cloud" LogResourceJAASGroup = "resource-jaas-group" ) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4a510e8c..29d48b77 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -379,6 +379,7 @@ func (p *jujuProvider) Resources(_ context.Context) []func() resource.Resource { func() resource.Resource { return NewSecretResource() }, func() resource.Resource { return NewAccessSecretResource() }, func() resource.Resource { return NewJAASAccessModelResource() }, + func() resource.Resource { return NewJAASAccessCloudResource() }, func() resource.Resource { return NewJAASGroupResource() }, } } diff --git a/internal/provider/resource_access_generic.go b/internal/provider/resource_access_generic.go index 8d9979ef..b759bf97 100644 --- a/internal/provider/resource_access_generic.go +++ b/internal/provider/resource_access_generic.go @@ -49,8 +49,8 @@ type Setter interface { // resourcer defines how the [genericJAASAccessResource] can query/save for information // on the target object. type resourcer interface { - Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessModel, names.Tag) - Save(ctx context.Context, setter Setter, info genericJAASAccessModel, tag names.Tag) diag.Diagnostics + Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) + Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics ImportHint() string } @@ -67,10 +67,10 @@ type genericJAASAccessResource struct { subCtx context.Context } -// genericJAASAccessModel represents a partial generic object for access management. +// genericJAASAccessData represents a partial generic object for access management. // This struct should be embedded into a struct that contains a field for a target object (normally a name or UUID). // Note that service accounts are treated as users but kept as a separate field for improved validation. -type genericJAASAccessModel struct { +type genericJAASAccessData struct { Users types.Set `tfsdk:"users"` ServiceAccounts types.Set `tfsdk:"service_accounts"` Groups types.Set `tfsdk:"groups"` @@ -312,7 +312,7 @@ func (resource *genericJAASAccessResource) Update(ctx context.Context, req resou resp.Diagnostics.Append(resource.save(ctx, &resp.State, plan, targetTag)...) } -func diffModels(plan, state genericJAASAccessModel, diag *diag.Diagnostics) (toAdd, toRemove genericJAASAccessModel) { +func diffModels(plan, state genericJAASAccessData, diag *diag.Diagnostics) (toAdd, toRemove genericJAASAccessData) { newUsers := diffSet(plan.Users, state.Users, diag) newGroups := diffSet(plan.Groups, state.Groups, diag) newServiceAccounts := diffSet(plan.ServiceAccounts, state.ServiceAccounts, diag) @@ -379,7 +379,7 @@ func (resource *genericJAASAccessResource) Delete(ctx context.Context, req resou } // modelToTuples return a list of tuples based on the access model provided. -func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAccessModel, diag *diag.Diagnostics) []juju.JaasTuple { +func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAccessData, diag *diag.Diagnostics) []juju.JaasTuple { var users []string var groups []string var serviceAccounts []string @@ -395,7 +395,7 @@ func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAc } var tuples []juju.JaasTuple userNameToTagf := func(s string) string { return names.NewUserTag(s).String() } - groupIDToTagf := func(s string) string { return jimmnames.NewGroupTag(s).String() } + groupIDToTagf := func(s string) string { return jimmnames.NewGroupTag(s).String() + "#member" } // Note that service accounts are treated as users but with an @serviceaccount domain. // We add the @serviceaccount domain by calling `EnsureValidServiceAccountId` so that the user writing the plan doesn't have to. // We can ignore the error below because the inputs have already gone through validation. @@ -410,7 +410,7 @@ func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAc } // tuplesToModel does the reverse of planToTuples converting a slice of tuples to an access model. -func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diagnostics) genericJAASAccessModel { +func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diagnostics) genericJAASAccessData { var users []string var groups []string var serviceAccounts []string @@ -435,7 +435,7 @@ func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diag users = append(users, userTag.Id()) } case jimmnames.GroupTagKind: - groups = append(groups, tag.Id()) + groups = append(groups, strings.ReplaceAll(tag.Id(), "#member", "")) } } userSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, users) @@ -444,7 +444,7 @@ func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diag diag.Append(errDiag...) serviceAccountSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, serviceAccounts) diag.Append(errDiag...) - var model genericJAASAccessModel + var model genericJAASAccessData model.Users = userSet model.Groups = groupSet model.ServiceAccounts = serviceAccountSet @@ -461,11 +461,11 @@ func assignTupleObject(baseTuple juju.JaasTuple, items []string, idToTag func(st return tuples } -func (a *genericJAASAccessResource) info(ctx context.Context, getter Getter, diags *diag.Diagnostics) (genericJAASAccessModel, names.Tag) { +func (a *genericJAASAccessResource) info(ctx context.Context, getter Getter, diags *diag.Diagnostics) (genericJAASAccessData, names.Tag) { return a.targetResource.Info(ctx, getter, diags) } -func (a *genericJAASAccessResource) save(ctx context.Context, setter Setter, info genericJAASAccessModel, tag names.Tag) diag.Diagnostics { +func (a *genericJAASAccessResource) save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics { return a.targetResource.Save(ctx, setter, info, tag) } @@ -505,7 +505,7 @@ func (a *genericJAASAccessResource) ImportState(ctx context.Context, req resourc resp.Diagnostics.AddError( "ImportState Failure", fmt.Sprintf("Malformed Import ID %q, "+ - "%s is not a valid tag", IDstr, resID[0]), + "%s is not a valid tag, expected %q", IDstr, resID[0], a.targetResource.ImportHint()), ) return } diff --git a/internal/provider/resource_access_generic_test.go b/internal/provider/resource_access_generic_test.go index 109d4241..fab04561 100644 --- a/internal/provider/resource_access_generic_test.go +++ b/internal/provider/resource_access_generic_test.go @@ -4,8 +4,13 @@ package provider import ( + "fmt" "testing" + "github.com/canonical/jimm-go-sdk/v3/api" + "github.com/canonical/jimm-go-sdk/v3/api/params" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stretchr/testify/assert" ) @@ -66,3 +71,104 @@ func TestAvoidAtSymbolValidation(t *testing.T) { }) } } + +// =============================== +// Helpers for jaas resource tests + +// newCheckAttribute returns a fetchComputedAttribute object that can be used in tests +// where you want to obtain the value of a computed attributed. +// +// The tag and resourceID fields are empty until this object is passed to a function +// like testAccCheckAttributeNotEmpty. +// +// The relationBuilder parameter allows you to create a custom string +// from the retrieved attribute value that can be used elsewhere in your test. +// The output of this function is stored on the tag field. +func newCheckAttribute(resourceName, attribute string, relationBuilder func(s string) string) fetchComputedAttribute { + var resourceID string + var tag string + return fetchComputedAttribute{ + resourceName: resourceName, + attribute: attribute, + resourceID: &resourceID, + tag: &tag, + tagConstructor: relationBuilder, + } +} + +type fetchComputedAttribute struct { + resourceName string + attribute string + resourceID *string + tag *string + tagConstructor func(s string) string +} + +// testAccCheckAttributeNotEmpty is used used alongside newCheckAttribute +// to fetch an attribute value and verify that it is not empty. +func testAccCheckAttributeNotEmpty(check fetchComputedAttribute) resource.TestCheckFunc { + return func(s *terraform.State) error { + // retrieve the resource by name from state + rs, ok := s.RootModule().Resources[check.resourceName] + if !ok { + return fmt.Errorf("Not found: %s", check.resourceName) + } + + val, ok := rs.Primary.Attributes[check.attribute] + if !ok { + return fmt.Errorf("%s is not set", check.attribute) + } + if val == "" { + return fmt.Errorf("%s is empty", check.attribute) + } + if check.resourceID == nil || check.tag == nil { + return fmt.Errorf("cannot set resource info, nil poiner") + } + *check.resourceID = val + *check.tag = check.tagConstructor(val) + return nil + } +} + +// testAccCheckJaasResourceAccess verifies that no direct relations exist +// between the object and target. +// Object and target are expected to be Juju tags of the form : +// Use newCheckAttribute to fetch and format resource tags from computed resources. +func testAccCheckJaasResourceAccess(relation string, object, target *string, expectedAccess bool) resource.TestCheckFunc { + return func(s *terraform.State) error { + if object == nil { + return fmt.Errorf("no object set") + } + if target == nil { + return fmt.Errorf("no target set") + } + conn, err := TestClient.Models.GetConnection(nil) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + jc := api.NewClient(conn) + req := params.ListRelationshipTuplesRequest{ + Tuple: params.RelationshipTuple{ + Object: *object, + Relation: relation, + TargetObject: *target, + }, + } + resp, err := jc.ListRelationshipTuples(&req) + if err != nil { + return err + } + hasAccess := len(resp.Tuples) != 0 + if hasAccess != expectedAccess { + var accessMsg string + if expectedAccess { + accessMsg = "access" + } else { + accessMsg = "no access" + } + return fmt.Errorf("expected %s for %s as %s to resource (%s), but access is %t", accessMsg, *object, relation, *target, hasAccess) + } + return nil + } +} diff --git a/internal/provider/resource_access_jaas_cloud.go b/internal/provider/resource_access_jaas_cloud.go new file mode 100644 index 00000000..8fde50ea --- /dev/null +++ b/internal/provider/resource_access_jaas_cloud.go @@ -0,0 +1,111 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/juju/names/v5" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &jaasAccessCloudResource{} +var _ resource.ResourceWithConfigure = &jaasAccessCloudResource{} +var _ resource.ResourceWithImportState = &jaasAccessCloudResource{} +var _ resource.ResourceWithConfigValidators = &jaasAccessCloudResource{} + +// NewJAASAccessCloudResource returns a new resource for JAAS cloud access. +func NewJAASAccessCloudResource() resource.Resource { + return &jaasAccessCloudResource{genericJAASAccessResource: genericJAASAccessResource{ + targetResource: cloudInfo{}, + resourceLogName: LogResourceJAASAccessCloud, + }} +} + +type cloudInfo struct{} + +// Info implements the [resourceInfo] interface, used to extract the info from a Terraform plan/state. +func (j cloudInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) { + cloudAccess := jaasAccessCloudResourceCloud{} + diag.Append(getter.Get(ctx, &cloudAccess)...) + accessCloud := genericJAASAccessData{ + ID: cloudAccess.ID, + Users: cloudAccess.Users, + Groups: cloudAccess.Groups, + ServiceAccounts: cloudAccess.ServiceAccounts, + Access: cloudAccess.Access, + } + // When importing, the cloud name will be empty + var tag names.Tag + if cloudAccess.CloudName.ValueString() != "" { + tag = names.NewCloudTag(cloudAccess.CloudName.ValueString()) + } + return accessCloud, tag +} + +// Save implements the [resourceInfo] interface, used to save info on Terraform's state. +func (j cloudInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics { + cloudAccess := jaasAccessCloudResourceCloud{ + CloudName: basetypes.NewStringValue(tag.Id()), + ID: info.ID, + Users: info.Users, + Groups: info.Groups, + ServiceAccounts: info.ServiceAccounts, + Access: info.Access, + } + return setter.Set(ctx, cloudAccess) +} + +// ImportHint implements [resourceInfo] and provides a hint to users on the import string format. +func (j cloudInfo) ImportHint() string { + return "cloud-:" +} + +type jaasAccessCloudResource struct { + genericJAASAccessResource +} + +type jaasAccessCloudResourceCloud struct { + CloudName types.String `tfsdk:"cloud_name"` + Users types.Set `tfsdk:"users"` + ServiceAccounts types.Set `tfsdk:"service_accounts"` + Groups types.Set `tfsdk:"groups"` + Access types.String `tfsdk:"access"` + + // ID required for imports + ID types.String `tfsdk:"id"` +} + +// Metadata returns metadata about the JAAS cloud access resource. +func (a *jaasAccessCloudResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_jaas_access_cloud" +} + +// Schema defines the schema for the JAAS cloud access resource. +func (a *jaasAccessCloudResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + attributes := a.partialAccessSchema() + attributes["cloud_name"] = schema.StringAttribute{ + Description: "The name of the cloud 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(names.IsValidCloud, "cloud must be a valid name"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + } + schema := schema.Schema{ + Description: "A resource that represents access to a cloud when using JAAS.", + Attributes: attributes, + } + resp.Schema = schema +} diff --git a/internal/provider/resource_access_jaas_cloud_test.go b/internal/provider/resource_access_jaas_cloud_test.go new file mode 100644 index 00000000..9f62f32f --- /dev/null +++ b/internal/provider/resource_access_jaas_cloud_test.go @@ -0,0 +1,177 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "errors" + "fmt" + "regexp" + "testing" + + jimmnames "github.com/canonical/jimm-go-sdk/v3/names" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/juju/names/v5" + internaltesting "github.com/juju/terraform-provider-juju/internal/testing" +) + +// This file has bare minimum tests for cloud access +// verifying that users, service accounts and groups +// can access a cloud. More extensive tests for +// generic jaas access are available in +// resource_access_jaas_model_test.go + +func TestAcc_ResourceJaasAccessCloud(t *testing.T) { + OnlyTestAgainstJAAS(t) + if testingCloud != LXDCloudTesting { + t.Skip(t.Name() + " only runs with LXD") + } + // Resource names + cloudAccessResourceName := "juju_jaas_access_cloud.test" + groupResourcename := "juju_jaas_group.test" + cloudName := "localhost" + accessSuccess := "can_addmodel" + accessFail := "bogus" + user := "foo@domain.com" + group := acctest.RandomWithPrefix("myGroup") + svcAcc := "test" + svcAccWithDomain := svcAcc + "@serviceaccount" + + // Objects for checking access + groupRelationF := func(s string) string { return jimmnames.NewGroupTag(s).String() + "#member" } + groupCheck := newCheckAttribute(groupResourcename, "uuid", groupRelationF) + userTag := names.NewUserTag(user).String() + svcAccTag := names.NewUserTag(svcAccWithDomain).String() + cloudTag := names.NewCloudTag(cloudName).String() + + // Test 0: Test an invalid access string. + // Test 1: Test adding a valid set user, group and service account. + // Test 2: Test importing works. + // Destroy: Test access is removed. + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + CheckDestroy: resource.ComposeTestCheckFunc( + testAccCheckJaasResourceAccess(accessSuccess, &userTag, &cloudTag, false), + testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, &cloudTag, false), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, &cloudTag, false), + ), + Steps: []resource.TestStep{ + { + Config: testAccResourceJaasAccessCloud(cloudName, accessFail, user, group, svcAcc), + ExpectError: regexp.MustCompile(fmt.Sprintf("(?s)unknown.*relation %s", accessFail)), + }, + { + Config: testAccResourceJaasAccessCloud(cloudName, accessSuccess, user, group, svcAcc), + Check: resource.ComposeTestCheckFunc( + testAccCheckAttributeNotEmpty(groupCheck), + testAccCheckJaasResourceAccess(accessSuccess, &userTag, &cloudTag, true), + testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, &cloudTag, true), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, &cloudTag, true), + resource.TestCheckResourceAttr(cloudAccessResourceName, "access", accessSuccess), + resource.TestCheckTypeSetElemAttr(cloudAccessResourceName, "users.*", user), + resource.TestCheckResourceAttr(cloudAccessResourceName, "users.#", "1"), + // Wrap this check so that the pointer has deferred evaluation. + func(s *terraform.State) error { + return resource.TestCheckTypeSetElemAttr(cloudAccessResourceName, "groups.*", *groupCheck.resourceID)(s) + }, + resource.TestCheckResourceAttr(cloudAccessResourceName, "groups.#", "1"), + resource.TestCheckTypeSetElemAttr(cloudAccessResourceName, "service_accounts.*", svcAcc), + resource.TestCheckResourceAttr(cloudAccessResourceName, "service_accounts.#", "1"), + ), + // The plan will not be empty because JAAS sets the special user "everyone@external" + // to have access to clouds by default. + // This behavior is tested in TestAcc_ResourceJaasAccessCloudImportState. + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAcc_ResourceJaasAccessCloudImportState(t *testing.T) { + OnlyTestAgainstJAAS(t) + if testingCloud != LXDCloudTesting { + t.Skip(t.Name() + " only runs with LXD") + } + cloudName := "localhost" + access := "can_addmodel" + + resourceName := "juju_jaas_access_cloud.test" + + // Test 0: Test importing works. + // Note that because JAAS allows the special user "everyone@external" to have add model access + // to clouds, we check that this user is present when we do an import with an empty config. + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceJaasAccessCloudEveryone(cloudName, access), + PlanOnly: true, + ImportStateVerify: false, + ImportStateCheck: func(is []*terraform.InstanceState) error { + if len(is) != 1 { + return errors.New("expected 1 instance in import state") + } + state := is[0] + checker := func(key, expected string) error { + if value, ok := state.Attributes[key]; !ok { + return fmt.Errorf("did not find attribute %s", key) + } else if value != expected { + return fmt.Errorf("value for attribute %s did not match, got %s expected %s", key, value, expected) + } + return nil + } + errs := make([]error, 1) + errs = append(errs, checker("users.0", "everyone@external")) + errs = append(errs, checker("users.#", "1")) + return errors.Join(errs...) + }, + ImportState: true, + ImportStateId: fmt.Sprintf("%s:%s", "cloud-"+cloudName, access), + ResourceName: resourceName, + }, + }, + }) +} + +func testAccResourceJaasAccessCloud(cloudName, access, user, group, svcAcc string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceJaasAccessCloud", + ` +resource "juju_jaas_group" "test" { + name = "{{ .Group }}" +} + +resource "juju_jaas_access_cloud" "test" { + cloud_name = "{{.Cloud}}" + access = "{{.Access}}" + users = ["{{.User}}"] + groups = [juju_jaas_group.test.uuid] + service_accounts = ["{{.SvcAcc}}"] +} +`, internaltesting.TemplateData{ + "Cloud": cloudName, + "Access": access, + "User": user, + "Group": group, + "SvcAcc": svcAcc, + }) +} + +func testAccResourceJaasAccessCloudEveryone(cloudName, access string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceJaasAccessCloudEmpty", + ` +resource "juju_jaas_access_cloud" "test" { + cloud_name = "{{.Cloud}}" + access = "{{.Access}}" + users = ["everyone@external"] +} +`, internaltesting.TemplateData{ + "Cloud": cloudName, + "Access": access, + }) +} diff --git a/internal/provider/resource_access_jaas_model.go b/internal/provider/resource_access_jaas_model.go index 757b751e..72231045 100644 --- a/internal/provider/resource_access_jaas_model.go +++ b/internal/provider/resource_access_jaas_model.go @@ -34,10 +34,10 @@ func NewJAASAccessModelResource() resource.Resource { type modelInfo struct{} // Info implements the [resourceInfo] interface, used to extract the info from a Terraform plan/state. -func (j modelInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessModel, names.Tag) { +func (j modelInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) { modelAccess := jaasAccessModelResourceModel{} diag.Append(getter.Get(ctx, &modelAccess)...) - accessModel := genericJAASAccessModel{ + accessModel := genericJAASAccessData{ ID: modelAccess.ID, Users: modelAccess.Users, Groups: modelAccess.Groups, @@ -48,7 +48,7 @@ func (j modelInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnosti } // Save implements the [resourceInfo] interface, used to save info on Terraform's state. -func (j modelInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessModel, tag names.Tag) diag.Diagnostics { +func (j modelInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics { modelAccess := jaasAccessModelResourceModel{ ModelUUID: basetypes.NewStringValue(tag.Id()), ID: info.ID, diff --git a/internal/provider/resource_access_jaas_model_test.go b/internal/provider/resource_access_jaas_model_test.go index 15b427c8..71458cf3 100644 --- a/internal/provider/resource_access_jaas_model_test.go +++ b/internal/provider/resource_access_jaas_model_test.go @@ -8,8 +8,7 @@ import ( "regexp" "testing" - "github.com/canonical/jimm-go-sdk/v3/api" - "github.com/canonical/jimm-go-sdk/v3/api/params" + jimmnames "github.com/canonical/jimm-go-sdk/v3/names" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/plancheck" @@ -20,14 +19,20 @@ import ( func TestAcc_ResourceJaasAccessModel(t *testing.T) { OnlyTestAgainstJAAS(t) + + // Resource names + resourceName := "juju_jaas_access_model.test" modelName := acctest.RandomWithPrefix("tf-jaas-access-model") accessSuccess := "writer" accessFail := "bogus" userOne := "foo@domain.com" userTwo := "bar@domain.com" - var modelUUID string - resourceName := "juju_jaas_access_model.test" + // Objects for checking access + newModelTagF := func(s string) string { return names.NewModelTag(s).String() } + modelCheck := newCheckAttribute(resourceName, "model_uuid", newModelTagF) + userOneTag := names.NewUserTag(userOne).String() + userTwoTag := names.NewUserTag(userTwo).String() // Test 0: Test an invalid access string. // Test 1: Test adding a valid set of users. @@ -37,7 +42,7 @@ func TestAcc_ResourceJaasAccessModel(t *testing.T) { PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: frameworkProviderFactories, CheckDestroy: resource.ComposeTestCheckFunc( - testAccCheckJaasModelAccess(userOne, accessSuccess, &modelUUID, false), + testAccCheckJaasResourceAccess(accessSuccess, &userOneTag, modelCheck.tag, false), ), Steps: []resource.TestStep{ { @@ -47,9 +52,9 @@ func TestAcc_ResourceJaasAccessModel(t *testing.T) { { Config: testAccResourceJaasAccessModelTwoUsers(modelName, accessSuccess, userOne, userTwo), Check: resource.ComposeTestCheckFunc( - testAccCheckModelUUIDNotEmpty(resourceName, &modelUUID), - testAccCheckJaasModelAccess(userOne, accessSuccess, &modelUUID, true), - testAccCheckJaasModelAccess(userTwo, accessSuccess, &modelUUID, true), + testAccCheckAttributeNotEmpty(modelCheck), + testAccCheckJaasResourceAccess(accessSuccess, &userOneTag, modelCheck.tag, true), + testAccCheckJaasResourceAccess(accessSuccess, &userTwoTag, modelCheck.tag, true), resource.TestCheckResourceAttr(resourceName, "access", accessSuccess), resource.TestCheckTypeSetElemAttr(resourceName, "users.*", "foo@domain.com"), resource.TestCheckTypeSetElemAttr(resourceName, "users.*", "bar@domain.com"), @@ -65,8 +70,8 @@ func TestAcc_ResourceJaasAccessModel(t *testing.T) { { Config: testAccResourceJaasAccessModelOneUser(modelName, accessSuccess, userOne), Check: resource.ComposeTestCheckFunc( - testAccCheckJaasModelAccess(userOne, accessSuccess, &modelUUID, true), - testAccCheckJaasModelAccess(userTwo, accessSuccess, &modelUUID, false), + testAccCheckJaasResourceAccess(accessSuccess, &userOneTag, modelCheck.tag, true), + testAccCheckJaasResourceAccess(accessSuccess, &userTwoTag, modelCheck.tag, false), resource.TestCheckResourceAttr(resourceName, "access", accessSuccess), resource.TestCheckTypeSetElemAttr(resourceName, "users.*", "foo@domain.com"), resource.TestCheckResourceAttr(resourceName, "users.#", "1"), @@ -81,6 +86,63 @@ func TestAcc_ResourceJaasAccessModel(t *testing.T) { }) } +// TestAcc_ResourceJaasAccessModelAllTypes tests that all types +// i.e. users, groups and services accounts can successfully +// receive access to a model. +func TestAcc_ResourceJaasAccessModelAllTypes(t *testing.T) { + OnlyTestAgainstJAAS(t) + + // Resource names + modelResourceName := "juju_jaas_access_model.test" + groupResourcename := "juju_jaas_group.test" + modelName := acctest.RandomWithPrefix("tf-jaas-access-model") + access := "writer" + user := "foo@domain.com" + svcAcc := "test" + svcAccWithDomain := svcAcc + "@serviceaccount" + group := acctest.RandomWithPrefix("myGroup") + + // Objects for checking access + newModelTagF := func(s string) string { return names.NewModelTag(s).String() } + modelCheck := newCheckAttribute(modelResourceName, "model_uuid", newModelTagF) + groupRelationF := func(s string) string { return jimmnames.NewGroupTag(s).String() + "#member" } + groupCheck := newCheckAttribute(groupResourcename, "uuid", groupRelationF) + userTag := names.NewUserTag(user).String() + svcAccTag := names.NewUserTag(svcAccWithDomain).String() + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + CheckDestroy: resource.ComposeTestCheckFunc( + testAccCheckJaasResourceAccess(access, &userTag, modelCheck.tag, false), + testAccCheckJaasResourceAccess(access, &svcAccTag, modelCheck.tag, false), + testAccCheckJaasResourceAccess(access, groupCheck.tag, modelCheck.tag, false), + ), + Steps: []resource.TestStep{ + { + Config: testAccResourceJaasAccessModelAllTypes(modelName, access, user, group, svcAcc), + Check: resource.ComposeTestCheckFunc( + testAccCheckAttributeNotEmpty(modelCheck), + testAccCheckAttributeNotEmpty(groupCheck), + testAccCheckJaasResourceAccess(access, &userTag, modelCheck.tag, true), + testAccCheckJaasResourceAccess(access, &svcAccTag, modelCheck.tag, true), + testAccCheckJaasResourceAccess(access, groupCheck.tag, modelCheck.tag, true), + resource.TestCheckResourceAttr(modelResourceName, "access", access), + resource.TestCheckTypeSetElemAttr(modelResourceName, "users.*", user), + resource.TestCheckResourceAttr(modelResourceName, "users.#", "1"), + // Wrap this check so that the pointer has deferred evaluation. + func(s *terraform.State) error { + return resource.TestCheckTypeSetElemAttr(modelResourceName, "groups.*", *groupCheck.resourceID)(s) + }, + resource.TestCheckResourceAttr(modelResourceName, "groups.#", "1"), + resource.TestCheckTypeSetElemAttr(modelResourceName, "service_accounts.*", svcAcc), + resource.TestCheckResourceAttr(modelResourceName, "service_accounts.#", "1"), + ), + }, + }, + }) +} + // TestAcc_ResourceJaasAccessModelAdmin verifies behaviour when setting admin access. // When a model is created, it is expected that the model owner is also a model admin. // Test that the refresh plan is not empty if the model owner is not included and verify @@ -88,18 +150,24 @@ func TestAcc_ResourceJaasAccessModel(t *testing.T) { func TestAcc_ResourceJaasAccessModelAdmin(t *testing.T) { OnlyTestAgainstJAAS(t) expectedResourceOwner() + + // Resource names + resourceName := "juju_jaas_access_model.test" modelName := acctest.RandomWithPrefix("tf-jaas-access-model") accessAdmin := "administrator" userOne := "foo@domain.com" - var modelUUID string - resourceName := "juju_jaas_access_model.test" + // Objects for checking access + resourceOwnerTag := names.NewUserTag(expectedResourceOwner()).String() + newModelTagF := func(s string) string { return names.NewModelTag(s).String() } + modelCheck := newCheckAttribute(resourceName, "model_uuid", newModelTagF) + userOneTag := names.NewUserTag(userOne).String() resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: frameworkProviderFactories, CheckDestroy: resource.ComposeTestCheckFunc( - testAccCheckJaasModelAccess(userOne, accessAdmin, &modelUUID, false), + testAccCheckJaasResourceAccess(accessAdmin, &userOneTag, modelCheck.tag, false), // TODO(Kian): The owner keeps access to the model after the destroy model command is // issued so that they can monitor the progress. Determine if there is a way to ensure // that relation is also eventually removed. @@ -109,9 +177,9 @@ func TestAcc_ResourceJaasAccessModelAdmin(t *testing.T) { { Config: testAccResourceJaasAccessModelOneUser(modelName, accessAdmin, userOne), Check: resource.ComposeTestCheckFunc( - testAccCheckModelUUIDNotEmpty(resourceName, &modelUUID), - testAccCheckJaasModelAccess(userOne, accessAdmin, &modelUUID, true), - testAccCheckJaasModelAccess(expectedResourceOwner(), accessAdmin, &modelUUID, true), + testAccCheckAttributeNotEmpty(modelCheck), + testAccCheckJaasResourceAccess(accessAdmin, &userOneTag, modelCheck.tag, true), + testAccCheckJaasResourceAccess(accessAdmin, &resourceOwnerTag, modelCheck.tag, true), resource.TestCheckResourceAttr(resourceName, "access", accessAdmin), resource.TestCheckTypeSetElemAttr(resourceName, "users.*", "foo@domain.com"), resource.TestCheckResourceAttr(resourceName, "users.#", "1"), @@ -124,13 +192,18 @@ func TestAcc_ResourceJaasAccessModelAdmin(t *testing.T) { func TestAcc_ResourceJaasAccessModelChangingAccessReplacesResource(t *testing.T) { OnlyTestAgainstJAAS(t) + + // Resource names + resourceName := "juju_jaas_access_model.test" modelName := acctest.RandomWithPrefix("tf-jaas-access-model") accessWriter := "writer" accessReader := "reader" userOne := "foo@domain.com" - var modelUUID string - resourceName := "juju_jaas_access_model.test" + // Objects for checking access + newModelTagF := func(s string) string { return names.NewModelTag(s).String() } + modelCheck := newCheckAttribute(resourceName, "model_uuid", newModelTagF) + userOneTag := names.NewUserTag(userOne).String() // Test 1: Test adding a valid user. // Test 2: Test updating model access string and see the resource will be replaced. @@ -138,14 +211,14 @@ func TestAcc_ResourceJaasAccessModelChangingAccessReplacesResource(t *testing.T) PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: frameworkProviderFactories, CheckDestroy: resource.ComposeTestCheckFunc( - testAccCheckJaasModelAccess(userOne, accessWriter, &modelUUID, false), + testAccCheckJaasResourceAccess(accessWriter, &userOneTag, modelCheck.tag, false), ), Steps: []resource.TestStep{ { Config: testAccResourceJaasAccessModelOneUser(modelName, accessWriter, userOne), Check: resource.ComposeTestCheckFunc( - testAccCheckModelUUIDNotEmpty(resourceName, &modelUUID), - testAccCheckJaasModelAccess(userOne, accessWriter, &modelUUID, true), + testAccCheckAttributeNotEmpty(modelCheck), + testAccCheckJaasResourceAccess(accessWriter, &userOneTag, modelCheck.tag, true), resource.TestCheckResourceAttr(resourceName, "access", accessWriter), resource.TestCheckTypeSetElemAttr(resourceName, "users.*", "foo@domain.com"), resource.TestCheckResourceAttr(resourceName, "users.#", "1"), @@ -165,27 +238,32 @@ func TestAcc_ResourceJaasAccessModelChangingAccessReplacesResource(t *testing.T) func TestAcc_ResourceJaasAccessModelServiceAccountAndUsers(t *testing.T) { OnlyTestAgainstJAAS(t) + + // Resource names + resourceName := "juju_jaas_access_model.test" modelName := acctest.RandomWithPrefix("tf-jaas-access-model") accessSuccess := "writer" svcAccountOne := "foo-1" svcAccountTwo := "foo-2" user := "bob@domain.com" - svcAccountOneWithDomain := svcAccountOne + "@serviceaccount" - svcAccountTwoWithDomain := svcAccountTwo + "@serviceaccount" - var modelUUID string - resourceName := "juju_jaas_access_model.test" + // Objects for checking access + newModelTagF := func(s string) string { return names.NewModelTag(s).String() } + modelCheck := newCheckAttribute(resourceName, "model_uuid", newModelTagF) + userTag := names.NewUserTag(user).String() + svcAccOneTag := names.NewUserTag(svcAccountOne + "@serviceaccount").String() + svcAccTwoTag := names.NewUserTag(svcAccountTwo + "@serviceaccount").String() // Test 0: Test adding an invalid service account tag - // Test 0: Test adding a valid service account. - // Test 1: Test adding an additional service account and user. + // Test 1: Test adding a valid service account. + // Test 2: Test adding an additional service account and user. resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: frameworkProviderFactories, CheckDestroy: resource.ComposeTestCheckFunc( - testAccCheckJaasModelAccess(svcAccountOneWithDomain, accessSuccess, &modelUUID, false), - testAccCheckJaasModelAccess(svcAccountTwoWithDomain, accessSuccess, &modelUUID, false), - testAccCheckJaasModelAccess(user, accessSuccess, &modelUUID, false), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccOneTag, modelCheck.tag, false), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccTwoTag, modelCheck.tag, false), + testAccCheckJaasResourceAccess(accessSuccess, &userTag, modelCheck.tag, false), ), Steps: []resource.TestStep{ { @@ -196,8 +274,8 @@ func TestAcc_ResourceJaasAccessModelServiceAccountAndUsers(t *testing.T) { { Config: testAccResourceJaasAccessModelOneSvcAccount(modelName, accessSuccess, svcAccountOne), Check: resource.ComposeTestCheckFunc( - testAccCheckModelUUIDNotEmpty(resourceName, &modelUUID), - testAccCheckJaasModelAccess(svcAccountOneWithDomain, accessSuccess, &modelUUID, true), + testAccCheckAttributeNotEmpty(modelCheck), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccOneTag, modelCheck.tag, true), resource.TestCheckResourceAttr(resourceName, "access", accessSuccess), resource.TestCheckTypeSetElemAttr(resourceName, "service_accounts.*", svcAccountOne), resource.TestCheckResourceAttr(resourceName, "service_accounts.#", "1"), @@ -206,10 +284,10 @@ func TestAcc_ResourceJaasAccessModelServiceAccountAndUsers(t *testing.T) { { Config: testAccResourceJaasAccessModelSvcAccsAndUser(modelName, accessSuccess, user, svcAccountOne, svcAccountTwo), Check: resource.ComposeTestCheckFunc( - testAccCheckModelUUIDNotEmpty(resourceName, &modelUUID), - testAccCheckJaasModelAccess(user, accessSuccess, &modelUUID, true), - testAccCheckJaasModelAccess(svcAccountOneWithDomain, accessSuccess, &modelUUID, true), - testAccCheckJaasModelAccess(svcAccountTwoWithDomain, accessSuccess, &modelUUID, true), + testAccCheckAttributeNotEmpty(modelCheck), + testAccCheckJaasResourceAccess(accessSuccess, &userTag, modelCheck.tag, true), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccOneTag, modelCheck.tag, true), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccTwoTag, modelCheck.tag, true), resource.TestCheckResourceAttr(resourceName, "access", accessSuccess), resource.TestCheckTypeSetElemAttr(resourceName, "users.*", user), resource.TestCheckTypeSetElemAttr(resourceName, "service_accounts.*", svcAccountOne), @@ -303,6 +381,34 @@ resource "juju_jaas_access_model" "test" { }) } +func testAccResourceJaasAccessModelAllTypes(modelName, access, user, group, svcAcc string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceJaasAccessModelTwoUsers", + ` +resource "juju_model" "test-model" { + name = "{{.ModelName}}" +} + +resource "juju_jaas_group" "test" { + name = "{{ .Group }}" +} + +resource "juju_jaas_access_model" "test" { + model_uuid = juju_model.test-model.id + access = "{{.Access}}" + users = ["{{.User}}"] + groups = [juju_jaas_group.test.uuid] + service_accounts = ["{{.SvcAcc}}"] +} +`, internaltesting.TemplateData{ + "ModelName": modelName, + "Access": access, + "Group": group, + "User": user, + "SvcAcc": svcAcc, + }) +} + func testAccResourceJaasAccessModelOneSvcAccount(modelName, access, svcAcc string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceJaasAccessModelOneSvcAccount", @@ -345,61 +451,3 @@ resource "juju_jaas_access_model" "test" { "SvcAccTwo": svcAccTwo, }) } - -func testAccCheckModelUUIDNotEmpty(resourceName string, modelUUID *string) resource.TestCheckFunc { - return func(s *terraform.State) error { - // retrieve the resource by name from state - rs, ok := s.RootModule().Resources[resourceName] - if !ok { - return fmt.Errorf("Not found: %s", resourceName) - } - - val, ok := rs.Primary.Attributes["model_uuid"] - if !ok { - return fmt.Errorf("Model UUID is not set") - } - if val == "" { - return fmt.Errorf("Model UUID is empty") - } - if modelUUID == nil { - return fmt.Errorf("cannot set model UUID, nil poiner") - } - *modelUUID = val - return nil - } -} - -func testAccCheckJaasModelAccess(user, relation string, modelUUID *string, expectedAccess bool) resource.TestCheckFunc { - return func(s *terraform.State) error { - if modelUUID == nil { - return fmt.Errorf("no model UUID set") - } - conn, err := TestClient.Models.GetConnection(nil) - if err != nil { - return err - } - defer func() { _ = conn.Close() }() - jc := api.NewClient(conn) - req := params.CheckRelationRequest{ - Tuple: params.RelationshipTuple{ - Object: names.NewUserTag(user).String(), - Relation: relation, - TargetObject: names.NewModelTag(*modelUUID).String(), - }, - } - resp, err := jc.CheckRelation(&req) - if err != nil { - return err - } - if resp.Allowed != expectedAccess { - var access string - if expectedAccess { - access = "access" - } else { - access = "no access" - } - return fmt.Errorf("expected %s for user %s as %s to model (%s), but access is %t", access, user, relation, *modelUUID, resp.Allowed) - } - return nil - } -}