diff --git a/.github/workflows/test_integration_jaas.yaml b/.github/workflows/test_integration_jaas.yaml index 876ce036..aba0d944 100644 --- a/.github/workflows/test_integration_jaas.yaml +++ b/.github/workflows/test_integration_jaas.yaml @@ -60,7 +60,7 @@ jobs: uses: canonical/jimm/.github/actions/test-server@v3 id: jaas with: - jimm-version: v3.1.10 + jimm-version: v3.1.13 juju-channel: 3/stable ghcr-pat: ${{ secrets.GITHUB_TOKEN }} - name: Setup microk8s for juju_kubernetes_cloud test diff --git a/go.mod b/go.mod index 750c85c6..aac1aada 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( ) require ( - github.com/canonical/jimm-go-sdk/v3 v3.0.5 + github.com/canonical/jimm-go-sdk/v3 v3.0.6 github.com/dustin/go-humanize v1.0.1 github.com/hashicorp/terraform-json v0.22.1 github.com/hashicorp/terraform-plugin-framework v1.11.0 diff --git a/go.sum b/go.sum index e1993619..e212947c 100644 --- a/go.sum +++ b/go.sum @@ -99,8 +99,8 @@ github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZ github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/canonical/go-dqlite v1.21.0 h1:4gLDdV2GF+vg0yv9Ff+mfZZNQ1JGhnQ3GnS2GeZPHfA= github.com/canonical/go-dqlite v1.21.0/go.mod h1:Uvy943N8R4CFUAs59A1NVaziWY9nJ686lScY7ywurfg= -github.com/canonical/jimm-go-sdk/v3 v3.0.5 h1:eQvn35wlmv+uNfyB7FHm+SkCigBu0x2VS1FlsaNor4Q= -github.com/canonical/jimm-go-sdk/v3 v3.0.5/go.mod h1:xcJrWTpLHSw3Z16/1Zcvh31awlwIzjXdrYUYCVZhc5s= +github.com/canonical/jimm-go-sdk/v3 v3.0.6 h1:ovQAEb5R5sSl7Edn27QTi/IyCX93xd87jE9ygj14mG0= +github.com/canonical/jimm-go-sdk/v3 v3.0.6/go.mod h1:xcJrWTpLHSw3Z16/1Zcvh31awlwIzjXdrYUYCVZhc5s= github.com/canonical/lxd v0.0.0-20231214113525-e676fc63c50a h1:Tfo/MzXK5GeG7gzSHqxGeY/669Mhh5ea43dn1mRDnk8= github.com/canonical/lxd v0.0.0-20231214113525-e676fc63c50a/go.mod h1:UxfHGKFoRjgu1NUA9EFiR++dKvyAiT0h9HT0ffMlzjc= github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= diff --git a/internal/juju/interfaces.go b/internal/juju/interfaces.go index e2cd924d..1cfec627 100644 --- a/internal/juju/interfaces.go +++ b/internal/juju/interfaces.go @@ -94,6 +94,10 @@ type JaasAPIClient interface { GetGroup(req *jaasparams.GetGroupRequest) (jaasparams.GetGroupResponse, error) RenameGroup(req *jaasparams.RenameGroupRequest) error RemoveGroup(req *jaasparams.RemoveGroupRequest) error + AddRole(req *jaasparams.AddRoleRequest) (jaasparams.AddRoleResponse, error) + GetRole(req *jaasparams.GetRoleRequest) (jaasparams.GetRoleResponse, error) + RenameRole(req *jaasparams.RenameRoleRequest) error + RemoveRole(req *jaasparams.RemoveRoleRequest) error } // KubernetesCloudAPIClient defines the set of methods that the Kubernetes cloud API provides. diff --git a/internal/juju/jaas.go b/internal/juju/jaas.go index 7784ae90..fe839531 100644 --- a/internal/juju/jaas.go +++ b/internal/juju/jaas.go @@ -217,3 +217,77 @@ func (jc *jaasClient) RemoveGroup(name string) error { req := params.RemoveGroupRequest{Name: name} return client.RemoveGroup(&req) } + +type JaasRole struct { + Name string + UUID string +} + +// AddRole attempts to create a new role with the provided name. +func (jc *jaasClient) AddRole(name string) (string, error) { + conn, err := jc.GetConnection(nil) + if err != nil { + return "", err + } + defer func() { _ = conn.Close() }() + + client := jc.getJaasApiClient(conn) + req := params.AddRoleRequest{Name: name} + + resp, err := client.AddRole(&req) + if err != nil { + return "", err + } + return resp.UUID, nil +} + +// ReadRoleByUUID attempts to read a role that matches the provided UUID. +func (jc *jaasClient) ReadRoleByUUID(uuid string) (*JaasRole, error) { + return jc.readRole(¶ms.GetRoleRequest{UUID: uuid}) +} + +// ReadRoleByName attempts to read a role that matches the provided name. +func (jc *jaasClient) ReadRoleByName(name string) (*JaasRole, error) { + return jc.readRole(¶ms.GetRoleRequest{Name: name}) +} + +func (jc *jaasClient) readRole(req *params.GetRoleRequest) (*JaasRole, error) { + conn, err := jc.GetConnection(nil) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + + client := jc.getJaasApiClient(conn) + resp, err := client.GetRole(req) + if err != nil { + return nil, err + } + return &JaasRole{Name: resp.Name, UUID: resp.UUID}, nil +} + +// RenameRole attempts to rename a role that matches the provided name. +func (jc *jaasClient) RenameRole(name, newName string) error { + conn, err := jc.GetConnection(nil) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + client := jc.getJaasApiClient(conn) + req := params.RenameRoleRequest{Name: name, NewName: newName} + return client.RenameRole(&req) +} + +// RemoveRole attempts to remove a role that matches the provided name. +func (jc *jaasClient) RemoveRole(name string) error { + conn, err := jc.GetConnection(nil) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + client := jc.getJaasApiClient(conn) + req := params.RemoveRoleRequest{Name: name} + return client.RemoveRole(&req) +} diff --git a/internal/juju/jaas_test.go b/internal/juju/jaas_test.go index 6f4e7f67..7d051bdb 100644 --- a/internal/juju/jaas_test.go +++ b/internal/juju/jaas_test.go @@ -223,6 +223,76 @@ func (s *JaasSuite) TestRemoveGroup() { s.Require().NoError(err) } +func (s *JaasSuite) TestAddRole() { + defer s.setupMocks(s.T()).Finish() + + name := "role" + req := ¶ms.AddRoleRequest{Name: name} + resp := params.AddRoleResponse{Role: params.Role{UUID: "uuid", Name: name}} + + s.mockJaasClient.EXPECT().AddRole(req).Return(resp, nil) + + client := s.getJaasClient() + uuid, err := client.AddRole(name) + s.Require().NoError(err) + s.Require().Equal(resp.UUID, uuid) +} + +func (s *JaasSuite) TestGetRole() { + defer s.setupMocks(s.T()).Finish() + + uuid := "uuid" + name := "role" + + req := ¶ms.GetRoleRequest{UUID: uuid} + resp := params.GetRoleResponse{Role: params.Role{UUID: uuid, Name: name}} + s.mockJaasClient.EXPECT().GetRole(req).Return(resp, nil) + + client := s.getJaasClient() + gotRole, err := client.ReadRoleByUUID(uuid) + s.Require().NoError(err) + s.Require().Equal(*gotRole, JaasRole{UUID: uuid, Name: name}) +} + +func (s *JaasSuite) TestGetRoleNotFound() { + defer s.setupMocks(s.T()).Finish() + + uuid := "uuid" + + req := ¶ms.GetRoleRequest{UUID: uuid} + s.mockJaasClient.EXPECT().GetRole(req).Return(params.GetRoleResponse{}, errors.New("role not found")) + + client := s.getJaasClient() + gotRole, err := client.ReadRoleByUUID(uuid) + s.Require().Error(err) + s.Require().Nil(gotRole) +} + +func (s *JaasSuite) TestRenameRole() { + defer s.setupMocks(s.T()).Finish() + + name := "name" + newName := "new-name" + req := ¶ms.RenameRoleRequest{Name: name, NewName: newName} + s.mockJaasClient.EXPECT().RenameRole(req).Return(nil) + + client := s.getJaasClient() + err := client.RenameRole(name, newName) + s.Require().NoError(err) +} + +func (s *JaasSuite) TestRemoveRole() { + defer s.setupMocks(s.T()).Finish() + + name := "role" + req := ¶ms.RemoveRoleRequest{Name: name} + s.mockJaasClient.EXPECT().RemoveRole(req).Return(nil) + + client := s.getJaasClient() + err := client.RemoveRole(name) + s.Require().NoError(err) +} + // In order for 'go test' to run this suite, we need to create // a normal test function and pass our suite to suite.Run func TestJaasSuite(t *testing.T) { diff --git a/internal/juju/mock_test.go b/internal/juju/mock_test.go index 0ea21317..94f73248 100644 --- a/internal/juju/mock_test.go +++ b/internal/juju/mock_test.go @@ -791,6 +791,21 @@ func (mr *MockJaasAPIClientMockRecorder) AddRelation(req any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddRelation", reflect.TypeOf((*MockJaasAPIClient)(nil).AddRelation), req) } +// AddRole mocks base method. +func (m *MockJaasAPIClient) AddRole(req *params.AddRoleRequest) (params.AddRoleResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddRole", req) + ret0, _ := ret[0].(params.AddRoleResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddRole indicates an expected call of AddRole. +func (mr *MockJaasAPIClientMockRecorder) AddRole(req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddRole", reflect.TypeOf((*MockJaasAPIClient)(nil).AddRole), req) +} + // GetGroup mocks base method. func (m *MockJaasAPIClient) GetGroup(req *params.GetGroupRequest) (params.GetGroupResponse, error) { m.ctrl.T.Helper() @@ -806,6 +821,21 @@ func (mr *MockJaasAPIClientMockRecorder) GetGroup(req any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroup", reflect.TypeOf((*MockJaasAPIClient)(nil).GetGroup), req) } +// GetRole mocks base method. +func (m *MockJaasAPIClient) GetRole(req *params.GetRoleRequest) (params.GetRoleResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRole", req) + ret0, _ := ret[0].(params.GetRoleResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRole indicates an expected call of GetRole. +func (mr *MockJaasAPIClientMockRecorder) GetRole(req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRole", reflect.TypeOf((*MockJaasAPIClient)(nil).GetRole), req) +} + // ListRelationshipTuples mocks base method. func (m *MockJaasAPIClient) ListRelationshipTuples(req *params.ListRelationshipTuplesRequest) (*params.ListRelationshipTuplesResponse, error) { m.ctrl.T.Helper() @@ -849,6 +879,20 @@ func (mr *MockJaasAPIClientMockRecorder) RemoveRelation(req any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveRelation", reflect.TypeOf((*MockJaasAPIClient)(nil).RemoveRelation), req) } +// RemoveRole mocks base method. +func (m *MockJaasAPIClient) RemoveRole(req *params.RemoveRoleRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveRole", req) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveRole indicates an expected call of RemoveRole. +func (mr *MockJaasAPIClientMockRecorder) RemoveRole(req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveRole", reflect.TypeOf((*MockJaasAPIClient)(nil).RemoveRole), req) +} + // RenameGroup mocks base method. func (m *MockJaasAPIClient) RenameGroup(req *params.RenameGroupRequest) error { m.ctrl.T.Helper() @@ -863,6 +907,20 @@ func (mr *MockJaasAPIClientMockRecorder) RenameGroup(req any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenameGroup", reflect.TypeOf((*MockJaasAPIClient)(nil).RenameGroup), req) } +// RenameRole mocks base method. +func (m *MockJaasAPIClient) RenameRole(req *params.RenameRoleRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenameRole", req) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenameRole indicates an expected call of RenameRole. +func (mr *MockJaasAPIClientMockRecorder) RenameRole(req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenameRole", reflect.TypeOf((*MockJaasAPIClient)(nil).RenameRole), req) +} + // MockKubernetesCloudAPIClient is a mock of KubernetesCloudAPIClient interface. type MockKubernetesCloudAPIClient struct { ctrl *gomock.Controller diff --git a/internal/provider/data_source_jaas_role.go b/internal/provider/data_source_jaas_role.go new file mode 100644 index 00000000..9dd058dd --- /dev/null +++ b/internal/provider/data_source_jaas_role.go @@ -0,0 +1,115 @@ +// 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/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/juju/terraform-provider-juju/internal/juju" +) + +type jaasRoleDataSource struct { + client *juju.Client + + // subCtx is the context created with the new tflog subsystem for applications. + subCtx context.Context +} + +// NewJAASRoleDataSource returns a new JAAS role data source instance. +func NewJAASRoleDataSource() datasource.DataSource { + return &jaasRoleDataSource{} +} + +type jaasRoleDataSourceModel struct { + Name types.String `tfsdk:"name"` + UUID types.String `tfsdk:"uuid"` +} + +// Metadata returns the metadata for the JAAS role data source. +func (d *jaasRoleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_jaas_role" +} + +// Schema defines the schema for JAAS roles. +func (d *jaasRoleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A data source representing a Juju JAAS Role.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The name of the role.", + Required: true, + }, + "uuid": schema.StringAttribute{ + Description: "The UUID of the role.", + Computed: true, + }, + }, + } +} + +// Configure sets up the JAAS role data source with the provider data. +func (d *jaasRoleDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.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 Data Source Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = client + d.subCtx = tflog.NewSubsystem(ctx, LogDataSourceJAASRole) +} + +// Read updates the role data source with the latest data from JAAS. +func (d *jaasRoleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + // Prevent panic if the provider has not been configured. + if d.client == nil { + addDSClientNotConfiguredError(&resp.Diagnostics, "jaas-role") + return + } + + var data jaasRoleDataSourceModel + + // Read Terraform configuration state into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Update the role with the latest data from JAAS + role, err := d.client.Jaas.ReadRoleByName(data.Name.String()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read role, got error: %v", err)) + return + } + data.UUID = types.StringValue(role.UUID) + d.trace(fmt.Sprintf("read role %q data source", data.Name)) + + // Save the updated role back to the state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (d *jaasRoleDataSource) trace(msg string, additionalFields ...map[string]interface{}) { + if d.subCtx == nil { + return + } + + //SubsystemTrace(subCtx, "datasource-jaas-role", "hello, world", map[string]interface{}{"foo": 123}) + // Output: + // {"@level":"trace","@message":"hello, world","@module":"juju.datasource-jaas-role","foo":123} + tflog.SubsystemTrace(d.subCtx, LogDataSourceJAASRole, msg, additionalFields...) +} diff --git a/internal/provider/data_source_jaas_role_test.go b/internal/provider/data_source_jaas_role_test.go new file mode 100644 index 00000000..14b5f0ee --- /dev/null +++ b/internal/provider/data_source_jaas_role_test.go @@ -0,0 +1,49 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "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_DataSourceJAASRole(t *testing.T) { + OnlyTestAgainstJAAS(t) + roleName := acctest.RandomWithPrefix("tf-jaas-role") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceJAASRole(roleName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.juju_jaas_role.test", "name", roleName), + resource.TestCheckResourceAttrSet("data.juju_jaas_role.test", "uuid"), + resource.TestCheckResourceAttrPair("juju_jaas_role.test", "uuid", "data.juju_jaas_role.test", "uuid"), + ), + }, + }, + }) +} + +func testAccDataSourceJAASRole(name string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccDataSourceJAASRole", + ` +resource "juju_jaas_role" "test" { + name = "{{ .Name }}" +} + +data "juju_jaas_role" "test" { + name = juju_jaas_role.test.name +} +`, internaltesting.TemplateData{ + "Name": name, + }) +} diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 0ac70790..fae65944 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -34,6 +34,7 @@ const ( LogResourceAccessSecret = "resource-access-secret" LogDataSourceJAASGroup = "datasource-jaas-group" + LogDataSourceJAASRole = "datasource-jaas-role" LogResourceJAASAccessModel = "resource-jaas-access-model" LogResourceJAASAccessCloud = "resource-jaas-access-cloud" @@ -42,6 +43,7 @@ const ( LogResourceJAASAccessController = "resource-jaas-access-controller" LogResourceJAASAccessSvcAcc = "resource-jaas-access-service-account" LogResourceJAASGroup = "resource-jaas-group" + LogResourceJAASRole = "resource-jaas-role" ) const LogResourceIntegration = "resource-integration" diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4507dd78..d257be43 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -375,10 +375,12 @@ func (p *jujuProvider) Resources(_ context.Context) []func() resource.Resource { func() resource.Resource { return NewJAASAccessModelResource() }, func() resource.Resource { return NewJAASAccessCloudResource() }, func() resource.Resource { return NewJAASAccessGroupResource() }, + func() resource.Resource { return NewJAASAccessRoleResource() }, func() resource.Resource { return NewJAASAccessOfferResource() }, func() resource.Resource { return NewJAASAccessControllerResource() }, func() resource.Resource { return NewJAASAccessServiceAccountResource() }, func() resource.Resource { return NewJAASGroupResource() }, + func() resource.Resource { return NewJAASRoleResource() }, } } @@ -394,6 +396,7 @@ func (p *jujuProvider) DataSources(_ context.Context) []func() datasource.DataSo func() datasource.DataSource { return NewOfferDataSource() }, func() datasource.DataSource { return NewSecretDataSource() }, func() datasource.DataSource { return NewJAASGroupDataSource() }, + func() datasource.DataSource { return NewJAASRoleDataSource() }, } } diff --git a/internal/provider/resource_access_generic.go b/internal/provider/resource_access_generic.go index 89f5ce71..f9edae22 100644 --- a/internal/provider/resource_access_generic.go +++ b/internal/provider/resource_access_generic.go @@ -75,6 +75,7 @@ type genericJAASAccessData struct { Users types.Set `tfsdk:"users"` ServiceAccounts types.Set `tfsdk:"service_accounts"` Groups types.Set `tfsdk:"groups"` + Roles types.Set `tfsdk:"roles"` Access types.String `tfsdk:"access"` // ID required for imports @@ -88,14 +89,15 @@ func (r *genericJAASAccessResource) ConfigValidators(ctx context.Context) []reso resourcevalidator.AtLeastOneOf( path.MatchRoot("users"), path.MatchRoot("groups"), + path.MatchRoot("roles"), path.MatchRoot("service_accounts"), ), } } -// partialAccessSchema returns a map of schema attributes for a JAAS access resource. +// baseAccessSchema returns a map of schema attributes for a JAAS access resource. // Access resources should use this schema and add any additional attributes e.g. name or uuid. -func (r *genericJAASAccessResource) partialAccessSchema() map[string]schema.Attribute { +func (r *genericJAASAccessResource) baseAccessSchema() map[string]schema.Attribute { return map[string]schema.Attribute{ "access": schema.StringAttribute{ Description: "Level of access to grant. Changing this value will replace the Terraform resource. Valid access levels are described at https://canonical-jaas-documentation.readthedocs-hosted.com/en/latest/reference/authorisation_model/#valid-relations", @@ -121,6 +123,14 @@ func (r *genericJAASAccessResource) partialAccessSchema() map[string]schema.Attr setvalidator.ValueStringsAre(ValidatorMatchString(jimmnames.IsValidGroupId, "group ID must be valid")), }, }, + "roles": schema.SetAttribute{ + Description: "List of roles to grant access.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(ValidatorMatchString(jimmnames.IsValidRoleId, "role ID must be valid")), + }, + }, "service_accounts": schema.SetAttribute{ Description: "List of service accounts to grant access.", Optional: true, @@ -242,6 +252,7 @@ func (resource *genericJAASAccessResource) Read(ctx context.Context, req resourc state.Users = newModel.Users state.Groups = newModel.Groups + state.Roles = newModel.Roles state.ServiceAccounts = newModel.ServiceAccounts state.Access = basetypes.NewStringValue(access) resp.Diagnostics.Append(resource.targetResource.Save(ctx, &resp.State, state, targetTag)...) @@ -316,17 +327,21 @@ func (resource *genericJAASAccessResource) Update(ctx context.Context, req resou 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) + newRoles := diffSet(plan.Roles, state.Roles, diag) newServiceAccounts := diffSet(plan.ServiceAccounts, state.ServiceAccounts, diag) toAdd.Users = newUsers toAdd.Groups = newGroups + toAdd.Roles = newRoles toAdd.ServiceAccounts = newServiceAccounts toAdd.Access = plan.Access removedUsers := diffSet(state.Users, plan.Users, diag) removedGroups := diffSet(state.Groups, plan.Groups, diag) + removedRoles := diffSet(state.Roles, plan.Roles, diag) removedServiceAccounts := diffSet(state.ServiceAccounts, plan.ServiceAccounts, diag) toRemove.Users = removedUsers toRemove.Groups = removedGroups + toRemove.Roles = removedRoles toRemove.ServiceAccounts = removedServiceAccounts toRemove.Access = plan.Access @@ -381,11 +396,15 @@ 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 genericJAASAccessData, diag *diag.Diagnostics) []juju.JaasTuple { - var users []string - var groups []string - var serviceAccounts []string + var ( + users []string + groups []string + roles []string + serviceAccounts []string + ) diag.Append(model.Users.ElementsAs(ctx, &users, false)...) diag.Append(model.Groups.ElementsAs(ctx, &groups, false)...) + diag.Append(model.Roles.ElementsAs(ctx, &roles, false)...) diag.Append(model.ServiceAccounts.ElementsAs(ctx, &serviceAccounts, false)...) if diag.HasError() { return []juju.JaasTuple{} @@ -397,6 +416,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() + "#member" } + roleIDToTagf := func(s string) string { return jimmnames.NewRoleTag(s).String() + "#assignee" } // 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. @@ -406,15 +426,19 @@ func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAc } tuples = append(tuples, assignTupleObject(baseTuple, users, userNameToTagf)...) tuples = append(tuples, assignTupleObject(baseTuple, groups, groupIDToTagf)...) + tuples = append(tuples, assignTupleObject(baseTuple, roles, roleIDToTagf)...) tuples = append(tuples, assignTupleObject(baseTuple, serviceAccounts, serviceAccIDToTagf)...) return tuples } // 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) genericJAASAccessData { - var users []string - var groups []string - var serviceAccounts []string + var ( + users []string + groups []string + roles []string + serviceAccounts []string + ) for _, tuple := range tuples { tag, err := jimmnames.ParseTag(tuple.Object) if err != nil { @@ -437,17 +461,22 @@ func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diag } case jimmnames.GroupTagKind: groups = append(groups, strings.ReplaceAll(tag.Id(), "#member", "")) + case jimmnames.RoleTagKind: + roles = append(roles, strings.ReplaceAll(tag.Id(), "#assignee", "")) } } userSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, users) diag.Append(errDiag...) groupSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, groups) diag.Append(errDiag...) + roleSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, roles) + diag.Append(errDiag...) serviceAccountSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, serviceAccounts) diag.Append(errDiag...) var model genericJAASAccessData model.Users = userSet model.Groups = groupSet + model.Roles = roleSet model.ServiceAccounts = serviceAccountSet return model } diff --git a/internal/provider/resource_access_jaas_cloud.go b/internal/provider/resource_access_jaas_cloud.go index 7ed50288..b448a967 100644 --- a/internal/provider/resource_access_jaas_cloud.go +++ b/internal/provider/resource_access_jaas_cloud.go @@ -89,6 +89,7 @@ type jaasAccessCloudResourceCloud struct { Users types.Set `tfsdk:"users"` ServiceAccounts types.Set `tfsdk:"service_accounts"` Groups types.Set `tfsdk:"groups"` + Roles types.Set `tfsdk:"roles"` Access types.String `tfsdk:"access"` // ID required for imports @@ -102,7 +103,7 @@ func (a *jaasAccessCloudResource) Metadata(_ context.Context, req resource.Metad // 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 := a.baseAccessSchema() 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, diff --git a/internal/provider/resource_access_jaas_cloud_test.go b/internal/provider/resource_access_jaas_cloud_test.go index adf1706d..8caeca94 100644 --- a/internal/provider/resource_access_jaas_cloud_test.go +++ b/internal/provider/resource_access_jaas_cloud_test.go @@ -32,17 +32,21 @@ func TestAcc_ResourceJaasAccessCloud(t *testing.T) { // Resource names cloudAccessResourceName := "juju_jaas_access_cloud.test" groupResourcename := "juju_jaas_group.test" + roleResourcename := "juju_jaas_role.test" cloudName := "localhost" accessSuccess := "can_addmodel" accessFail := "bogus" user := "foo@domain.com" group := acctest.RandomWithPrefix("myGroup") + role := acctest.RandomWithPrefix("myRole") 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) + roleRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() + "#assignee" } + roleCheck := newCheckAttribute(roleResourcename, "uuid", roleRelationF) userTag := names.NewUserTag(user).String() svcAccTag := names.NewUserTag(svcAccWithDomain).String() cloudTag := names.NewCloudTag(cloudName).String() @@ -57,17 +61,19 @@ func TestAcc_ResourceJaasAccessCloud(t *testing.T) { CheckDestroy: resource.ComposeTestCheckFunc( testAccCheckJaasResourceAccess(accessSuccess, &userTag, &cloudTag, false), testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, &cloudTag, false), + testAccCheckJaasResourceAccess(accessSuccess, roleCheck.tag, &cloudTag, false), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, &cloudTag, false), ), Steps: []resource.TestStep{ { - Config: testAccResourceJaasAccessCloud(cloudName, accessFail, user, group, svcAcc), + Config: testAccResourceJaasAccessCloud(cloudName, accessFail, user, group, svcAcc, role), ExpectError: regexp.MustCompile(fmt.Sprintf("(?s)unknown.*relation %s", accessFail)), }, { - Config: testAccResourceJaasAccessCloud(cloudName, accessSuccess, user, group, svcAcc), + Config: testAccResourceJaasAccessCloud(cloudName, accessSuccess, user, group, svcAcc, role), Check: resource.ComposeTestCheckFunc( testAccCheckAttributeNotEmpty(groupCheck), + testAccCheckAttributeNotEmpty(roleCheck), testAccCheckJaasResourceAccess(accessSuccess, &userTag, &cloudTag, true), testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, &cloudTag, true), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, &cloudTag, true), @@ -138,10 +144,14 @@ func TestAcc_ResourceJaasAccessCloudImportState(t *testing.T) { }) } -func testAccResourceJaasAccessCloud(cloudName, access, user, group, svcAcc string) string { +func testAccResourceJaasAccessCloud(cloudName, access, user, group, role, svcAcc string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceJaasAccessCloud", ` +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + resource "juju_jaas_group" "test" { name = "{{ .Group }}" } @@ -151,6 +161,7 @@ resource "juju_jaas_access_cloud" "test" { access = "{{.Access}}" users = ["{{.User}}"] groups = [juju_jaas_group.test.uuid] + roles = [juju_jaas_role.test.uuid] service_accounts = ["{{.SvcAcc}}"] } `, internaltesting.TemplateData{ @@ -158,6 +169,7 @@ resource "juju_jaas_access_cloud" "test" { "Access": access, "User": user, "Group": group, + "Role": role, "SvcAcc": svcAcc, }) } diff --git a/internal/provider/resource_access_jaas_controller.go b/internal/provider/resource_access_jaas_controller.go index 8b62c4b4..02098df4 100644 --- a/internal/provider/resource_access_jaas_controller.go +++ b/internal/provider/resource_access_jaas_controller.go @@ -67,6 +67,7 @@ type jaasAccessControllerResourceController struct { Users types.Set `tfsdk:"users"` ServiceAccounts types.Set `tfsdk:"service_accounts"` Groups types.Set `tfsdk:"groups"` + Roles types.Set `tfsdk:"roles"` Access types.String `tfsdk:"access"` // ID required for imports @@ -80,7 +81,7 @@ func (a *jaasAccessControllerResource) Metadata(_ context.Context, req resource. // Schema defines the schema for the JAAS controller access resource. func (a *jaasAccessControllerResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - attributes := a.partialAccessSchema() + attributes := a.baseAccessSchema() // The controller access schema has no target object. // The only target is the JAAS controller so we don't need user input. schema := schema.Schema{ diff --git a/internal/provider/resource_access_jaas_controller_test.go b/internal/provider/resource_access_jaas_controller_test.go index 5646e0df..5172cb2b 100644 --- a/internal/provider/resource_access_jaas_controller_test.go +++ b/internal/provider/resource_access_jaas_controller_test.go @@ -30,16 +30,20 @@ func TestAcc_ResourceJaasAccessController(t *testing.T) { // Resource names controllerAccessResourceName := "juju_jaas_access_controller.test" groupResourcename := "juju_jaas_group.test" + roleResourcename := "juju_jaas_role.test" accessSuccess := "administrator" accessFail := "bogus" user := "foo@domain.com" group := acctest.RandomWithPrefix("myGroup") + role := acctest.RandomWithPrefix("myRole") 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) + roleRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() + "#assignee" } + roleCheck := newCheckAttribute(roleResourcename, "uuid", roleRelationF) userTag := names.NewUserTag(user).String() svcAccTag := names.NewUserTag(svcAccWithDomain).String() controllerTag := names.NewControllerTag("jimm").String() @@ -54,15 +58,16 @@ func TestAcc_ResourceJaasAccessController(t *testing.T) { CheckDestroy: resource.ComposeTestCheckFunc( testAccCheckJaasResourceAccess(accessSuccess, &userTag, &controllerTag, false), testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, &controllerTag, false), + testAccCheckJaasResourceAccess(accessSuccess, roleCheck.tag, &controllerTag, false), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, &controllerTag, false), ), Steps: []resource.TestStep{ { - Config: testAccResourceJaasAccessController(accessFail, user, group, svcAcc), + Config: testAccResourceJaasAccessController(accessFail, user, group, svcAcc, role), ExpectError: regexp.MustCompile(fmt.Sprintf("(?s)unknown.*relation %s", accessFail)), }, { - Config: testAccResourceJaasAccessController(accessSuccess, user, group, svcAcc), + Config: testAccResourceJaasAccessController(accessSuccess, user, group, svcAcc, role), Check: resource.ComposeTestCheckFunc( testAccCheckAttributeNotEmpty(groupCheck), testAccCheckJaasResourceAccess(accessSuccess, &userTag, &controllerTag, true), @@ -132,10 +137,14 @@ func TestAcc_ResourceJaasAccessControllerImportState(t *testing.T) { }) } -func testAccResourceJaasAccessController(access, user, group, svcAcc string) string { +func testAccResourceJaasAccessController(access, user, group, svcAcc, role string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceJaasAccessController", ` +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + resource "juju_jaas_group" "test" { name = "{{ .Group }}" } @@ -144,12 +153,14 @@ resource "juju_jaas_access_controller" "test" { access = "{{.Access}}" users = ["{{.User}}"] groups = [juju_jaas_group.test.uuid] + roles = [juju_jaas_role.test.uuid] service_accounts = ["{{.SvcAcc}}"] } `, internaltesting.TemplateData{ "Access": access, "User": user, "Group": group, + "Role": role, "SvcAcc": svcAcc, }) } diff --git a/internal/provider/resource_access_jaas_group.go b/internal/provider/resource_access_jaas_group.go index 94159d26..baf86729 100644 --- a/internal/provider/resource_access_jaas_group.go +++ b/internal/provider/resource_access_jaas_group.go @@ -90,6 +90,7 @@ type jaasAccessModelResourceGroup struct { Users types.Set `tfsdk:"users"` ServiceAccounts types.Set `tfsdk:"service_accounts"` Groups types.Set `tfsdk:"groups"` + Roles types.Set `tfsdk:"roles"` Access types.String `tfsdk:"access"` // ID required for imports @@ -103,7 +104,7 @@ func (a *jaasAccessGroupResource) Metadata(_ context.Context, req resource.Metad // Schema defines the schema for the JAAS group access resource. func (a *jaasAccessGroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - attributes := a.partialAccessSchema() + attributes := a.baseAccessSchema() attributes["group_id"] = schema.StringAttribute{ Description: "The ID of the group for access management. If this is changed the resource will be deleted and a new resource will be created.", Required: true, diff --git a/internal/provider/resource_access_jaas_group_test.go b/internal/provider/resource_access_jaas_group_test.go index d4589f39..68d6d298 100644 --- a/internal/provider/resource_access_jaas_group_test.go +++ b/internal/provider/resource_access_jaas_group_test.go @@ -30,11 +30,13 @@ func TestAcc_ResourceJaasAccessGroup(t *testing.T) { groupOneResourcename := "juju_jaas_group.test" groupTwoResourceName := "juju_jaas_group.groupWithAccess" + roleResourcename := "juju_jaas_role.test" accessSuccess := "member" accessFail := "bogus" user := "foo@domain.com" groupOneName := acctest.RandomWithPrefix("group1") groupTwoName := acctest.RandomWithPrefix("group2") + role := acctest.RandomWithPrefix("myRole") svcAcc := "test" svcAccWithDomain := svcAcc + "@serviceaccount" @@ -43,6 +45,8 @@ func TestAcc_ResourceJaasAccessGroup(t *testing.T) { groupOneCheck := newCheckAttribute(groupOneResourcename, "uuid", groupRelationF) groupWithMemberRelationF := func(s string) string { return jimmnames.NewGroupTag(s).String() + "#member" } groupTwoCheck := newCheckAttribute(groupTwoResourceName, "uuid", groupWithMemberRelationF) + roleRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() + "#assignee" } + roleCheck := newCheckAttribute(roleResourcename, "uuid", roleRelationF) userTag := names.NewUserTag(user).String() svcAccTag := names.NewUserTag(svcAccWithDomain).String() @@ -56,18 +60,20 @@ func TestAcc_ResourceJaasAccessGroup(t *testing.T) { CheckDestroy: resource.ComposeTestCheckFunc( testAccCheckJaasResourceAccess(accessSuccess, &userTag, groupOneCheck.tag, false), testAccCheckJaasResourceAccess(accessSuccess, groupTwoCheck.tag, groupOneCheck.tag, false), + testAccCheckJaasResourceAccess(accessSuccess, roleCheck.tag, groupOneCheck.tag, false), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, groupOneCheck.tag, false), ), Steps: []resource.TestStep{ { - Config: testAccResourceJaasAccessGroup(groupOneName, accessFail, user, groupTwoName, svcAcc), + Config: testAccResourceJaasAccessGroup(groupOneName, accessFail, user, groupTwoName, svcAcc, role), ExpectError: regexp.MustCompile(fmt.Sprintf("(?s)unknown.*relation %s", accessFail)), }, { - Config: testAccResourceJaasAccessGroup(groupOneName, accessSuccess, user, groupTwoName, svcAcc), + Config: testAccResourceJaasAccessGroup(groupOneName, accessSuccess, user, groupTwoName, svcAcc, role), Check: resource.ComposeTestCheckFunc( testAccCheckAttributeNotEmpty(groupOneCheck), testAccCheckAttributeNotEmpty(groupTwoCheck), + testAccCheckAttributeNotEmpty(roleCheck), testAccCheckJaasResourceAccess(accessSuccess, &userTag, groupOneCheck.tag, true), testAccCheckJaasResourceAccess(accessSuccess, groupTwoCheck.tag, groupOneCheck.tag, true), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, groupOneCheck.tag, true), @@ -92,10 +98,14 @@ func TestAcc_ResourceJaasAccessGroup(t *testing.T) { }) } -func testAccResourceJaasAccessGroup(groupName, access, user, groupWithAccess, svcAcc string) string { +func testAccResourceJaasAccessGroup(groupName, access, user, groupWithAccess, svcAcc, role string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceJaasAccessGroup", ` +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + resource "juju_jaas_group" "test" { name = "{{ .Group }}" } @@ -109,6 +119,7 @@ resource "juju_jaas_access_group" "test" { access = "{{.Access}}" users = ["{{.User}}"] groups = [juju_jaas_group.groupWithAccess.uuid] + roles = [juju_jaas_role.test.uuid] service_accounts = ["{{.SvcAcc}}"] } `, internaltesting.TemplateData{ @@ -117,5 +128,6 @@ resource "juju_jaas_access_group" "test" { "User": user, "GroupWithAccess": groupWithAccess, "SvcAcc": svcAcc, + "Role": role, }) } diff --git a/internal/provider/resource_access_jaas_model.go b/internal/provider/resource_access_jaas_model.go index a0dabd0d..e94ccc94 100644 --- a/internal/provider/resource_access_jaas_model.go +++ b/internal/provider/resource_access_jaas_model.go @@ -84,6 +84,7 @@ type jaasAccessModelResourceModel struct { Users types.Set `tfsdk:"users"` ServiceAccounts types.Set `tfsdk:"service_accounts"` Groups types.Set `tfsdk:"groups"` + Roles types.Set `tfsdk:"roles"` Access types.String `tfsdk:"access"` // ID required for imports @@ -97,7 +98,7 @@ func (a *jaasAccessModelResource) Metadata(_ context.Context, req resource.Metad // Schema defines the schema for the JAAS model access resource. func (a *jaasAccessModelResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - attributes := a.partialAccessSchema() + attributes := a.baseAccessSchema() attributes["model_uuid"] = schema.StringAttribute{ Description: "The uuid of the model for access management. If this is changed the resource will be deleted and a new resource will be created.", Required: true, diff --git a/internal/provider/resource_access_jaas_model_test.go b/internal/provider/resource_access_jaas_model_test.go index 8a0e253a..69ebcc41 100644 --- a/internal/provider/resource_access_jaas_model_test.go +++ b/internal/provider/resource_access_jaas_model_test.go @@ -96,18 +96,22 @@ func TestAcc_ResourceJaasAccessModelAllTypes(t *testing.T) { // Resource names modelResourceName := "juju_jaas_access_model.test" groupResourcename := "juju_jaas_group.test" + roleResourcename := "juju_jaas_role.test" modelName := acctest.RandomWithPrefix("tf-jaas-access-model") access := "writer" user := "foo@domain.com" svcAcc := "test" svcAccWithDomain := svcAcc + "@serviceaccount" group := acctest.RandomWithPrefix("myGroup") + role := acctest.RandomWithPrefix("myRole") // 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) + roleRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() + "#assignee" } + roleCheck := newCheckAttribute(roleResourcename, "uuid", roleRelationF) userTag := names.NewUserTag(user).String() svcAccTag := names.NewUserTag(svcAccWithDomain).String() @@ -117,13 +121,15 @@ func TestAcc_ResourceJaasAccessModelAllTypes(t *testing.T) { CheckDestroy: resource.ComposeTestCheckFunc( testAccCheckJaasResourceAccess(access, &userTag, modelCheck.tag, false), testAccCheckJaasResourceAccess(access, &svcAccTag, modelCheck.tag, false), + testAccCheckJaasResourceAccess(access, roleCheck.tag, modelCheck.tag, false), testAccCheckJaasResourceAccess(access, groupCheck.tag, modelCheck.tag, false), ), Steps: []resource.TestStep{ { - Config: testAccResourceJaasAccessModelAllTypes(modelName, access, user, group, svcAcc), + Config: testAccResourceJaasAccessModelAllTypes(modelName, access, user, group, svcAcc, role), Check: resource.ComposeTestCheckFunc( testAccCheckAttributeNotEmpty(modelCheck), + testAccCheckAttributeNotEmpty(roleCheck), testAccCheckAttributeNotEmpty(groupCheck), testAccCheckJaasResourceAccess(access, &userTag, modelCheck.tag, true), testAccCheckJaasResourceAccess(access, &svcAccTag, modelCheck.tag, true), @@ -382,10 +388,14 @@ resource "juju_jaas_access_model" "test" { }) } -func testAccResourceJaasAccessModelAllTypes(modelName, access, user, group, svcAcc string) string { +func testAccResourceJaasAccessModelAllTypes(modelName, access, user, group, svcAcc, role string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceJaasAccessModelTwoUsers", ` +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + resource "juju_model" "test-model" { name = "{{.ModelName}}" } @@ -399,6 +409,7 @@ resource "juju_jaas_access_model" "test" { access = "{{.Access}}" users = ["{{.User}}"] groups = [juju_jaas_group.test.uuid] + roles = [juju_jaas_role.test.uuid] service_accounts = ["{{.SvcAcc}}"] } `, internaltesting.TemplateData{ @@ -407,6 +418,7 @@ resource "juju_jaas_access_model" "test" { "Group": group, "User": user, "SvcAcc": svcAcc, + "Role": role, }) } diff --git a/internal/provider/resource_access_jaas_offer.go b/internal/provider/resource_access_jaas_offer.go index 276cfdc1..e212a473 100644 --- a/internal/provider/resource_access_jaas_offer.go +++ b/internal/provider/resource_access_jaas_offer.go @@ -86,6 +86,7 @@ type jaasAccessOfferResourceOffer struct { Users types.Set `tfsdk:"users"` ServiceAccounts types.Set `tfsdk:"service_accounts"` Groups types.Set `tfsdk:"groups"` + Roles types.Set `tfsdk:"roles"` Access types.String `tfsdk:"access"` // ID required for imports @@ -99,7 +100,7 @@ func (a *jaasAccessOfferResource) Metadata(_ context.Context, req resource.Metad // Schema defines the schema for the JAAS offer access resource. func (a *jaasAccessOfferResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - attributes := a.partialAccessSchema() + attributes := a.baseAccessSchema() attributes["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, diff --git a/internal/provider/resource_access_jaas_offer_test.go b/internal/provider/resource_access_jaas_offer_test.go index 9f3ee2ad..bb504c88 100644 --- a/internal/provider/resource_access_jaas_offer_test.go +++ b/internal/provider/resource_access_jaas_offer_test.go @@ -29,16 +29,20 @@ func TestAcc_ResourceJaasAccessOffer(t *testing.T) { modelName := acctest.RandomWithPrefix("tf-test-offer") offerAccessResourceName := "juju_jaas_access_offer.test" groupResourcename := "juju_jaas_group.test" + roleResourcename := "juju_jaas_role.test" accessSuccess := "consumer" accessFail := "bogus" user := "foo@domain.com" group := acctest.RandomWithPrefix("myGroup") + role := acctest.RandomWithPrefix("myRole") 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) + roleRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() + "#assignee" } + roleCheck := newCheckAttribute(roleResourcename, "uuid", roleRelationF) offerRelationF := func(s string) string { return names.NewApplicationOfferTag(s).String() } offerCheck := newCheckAttribute(offerAccessResourceName, "offer_url", offerRelationF) userTag := names.NewUserTag(user).String() @@ -54,17 +58,19 @@ func TestAcc_ResourceJaasAccessOffer(t *testing.T) { CheckDestroy: resource.ComposeAggregateTestCheckFunc( testAccCheckJaasResourceAccess(accessSuccess, &userTag, offerCheck.tag, false), testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, offerCheck.tag, false), + testAccCheckJaasResourceAccess(accessSuccess, roleCheck.tag, offerCheck.tag, false), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, offerCheck.tag, false), ), Steps: []resource.TestStep{ { - Config: testAccResourceJaasAccessOffer(modelName, accessFail, user, group, svcAcc), + Config: testAccResourceJaasAccessOffer(modelName, accessFail, user, group, svcAcc, role), ExpectError: regexp.MustCompile(fmt.Sprintf("(?s)unknown.*relation %s", accessFail)), }, { - Config: testAccResourceJaasAccessOffer(modelName, accessSuccess, user, group, svcAcc), + Config: testAccResourceJaasAccessOffer(modelName, accessSuccess, user, group, svcAcc, role), Check: resource.ComposeTestCheckFunc( testAccCheckAttributeNotEmpty(groupCheck), + testAccCheckAttributeNotEmpty(roleCheck), testAccCheckAttributeNotEmpty(offerCheck), testAccCheckJaasResourceAccess(accessSuccess, &userTag, offerCheck.tag, true), testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, offerCheck.tag, true), @@ -90,7 +96,7 @@ func TestAcc_ResourceJaasAccessOffer(t *testing.T) { }) } -func testAccResourceJaasAccessOffer(modelName, access, user, group, svcAcc string) string { +func testAccResourceJaasAccessOffer(modelName, access, user, group, svcAcc, role string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceJaasAccessoffer", ` @@ -114,6 +120,10 @@ resource "juju_offer" "offerone" { endpoint = "sink" } +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + resource "juju_jaas_group" "test" { name = "{{ .Group }}" } @@ -123,6 +133,7 @@ resource "juju_jaas_access_offer" "test" { access = "{{.Access}}" users = ["{{.User}}"] groups = [juju_jaas_group.test.uuid] + roles = [juju_jaas_role.test.uuid] service_accounts = ["{{.SvcAcc}}"] } `, internaltesting.TemplateData{ @@ -131,5 +142,6 @@ resource "juju_jaas_access_offer" "test" { "User": user, "Group": group, "SvcAcc": svcAcc, + "Role": role, }) } diff --git a/internal/provider/resource_access_jaas_role.go b/internal/provider/resource_access_jaas_role.go new file mode 100644 index 00000000..6dead566 --- /dev/null +++ b/internal/provider/resource_access_jaas_role.go @@ -0,0 +1,138 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "context" + "errors" + + jimmnames "github.com/canonical/jimm-go-sdk/v3/names" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/juju/names/v5" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &jaasAccessRoleResource{} +var _ resource.ResourceWithConfigure = &jaasAccessRoleResource{} +var _ resource.ResourceWithImportState = &jaasAccessRoleResource{} +var _ resource.ResourceWithConfigValidators = &jaasAccessRoleResource{} + +// NewJAASAccessRoleResource returns a new resource for JAAS role access. +func NewJAASAccessRoleResource() resource.Resource { + return &jaasAccessRoleResource{genericJAASAccessResource: genericJAASAccessResource{ + targetResource: roleInfo{}, + resourceLogName: LogResourceJAASAccessGroup, + }} +} + +type roleInfo struct{} + +// Info implements the [resourceInfo] interface, used to extract the info from a Terraform plan/state. +func (j roleInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) { + roleAccess := jaasAccessModelResourceRole{} + diag.Append(getter.Get(ctx, &roleAccess)...) + accessGroup := genericJAASAccessData{ + ID: roleAccess.ID, + Users: roleAccess.Users, + Groups: roleAccess.Groups, + ServiceAccounts: roleAccess.ServiceAccounts, + Access: roleAccess.Access, + } + // When importing, the role name will be empty + var tag names.Tag + if roleAccess.RoleID.ValueString() != "" { + tag = jimmnames.NewRoleTag(roleAccess.RoleID.ValueString()) + } + return accessGroup, tag +} + +// Save implements the [resourceInfo] interface, used to save info on Terraform's state. +func (j roleInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics { + roleAccess := jaasAccessModelResourceRole{ + RoleID: basetypes.NewStringValue(tag.Id()), + ID: info.ID, + Users: info.Users, + Groups: info.Groups, + ServiceAccounts: info.ServiceAccounts, + Access: info.Access, + } + return setter.Set(ctx, roleAccess) +} + +// ImportHint implements [resourceInfo] and provides a hint to users on the import string format. +func (j roleInfo) ImportHint() string { + return ":" +} + +// TagFromID validates the id to be a valid role ID +// and returns a role tag. +func (j roleInfo) TagFromID(id string) (names.Tag, error) { + if !jimmnames.IsValidRoleId(id) { + return nil, errors.New("invalid role ID") + } + return jimmnames.NewRoleTag(id), nil +} + +type jaasAccessRoleResource struct { + genericJAASAccessResource +} + +type jaasAccessModelResourceRole struct { + RoleID types.String `tfsdk:"role_id"` + Users types.Set `tfsdk:"users"` + ServiceAccounts types.Set `tfsdk:"service_accounts"` + Groups types.Set `tfsdk:"roles"` + Access types.String `tfsdk:"access"` + + // ID required for imports + ID types.String `tfsdk:"id"` +} + +// Metadata returns metadata about the JAAS role access resource. +func (a *jaasAccessRoleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_jaas_access_role" +} + +// ConfigValidators sets validators for the resource. +func (r *jaasAccessRoleResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + NewRequiresJAASValidator(r.client), + resourcevalidator.AtLeastOneOf( + path.MatchRoot("users"), + path.MatchRoot("groups"), + path.MatchRoot("service_accounts"), + ), + } +} + +// Schema defines the schema for the JAAS role access resource. +func (a *jaasAccessRoleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + attributes := a.baseAccessSchema() + // we don't support assigning roles to roles, i.e., role-a -> assignee -> role-b. + delete(attributes, "roles") + attributes["role_id"] = schema.StringAttribute{ + Description: "The ID of the role 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(jimmnames.IsValidRoleId, "role must be a valid UUID"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + } + schema := schema.Schema{ + Description: "A resource that represents access to a role when using JAAS.", + Attributes: attributes, + } + resp.Schema = schema +} diff --git a/internal/provider/resource_access_jaas_role_test.go b/internal/provider/resource_access_jaas_role_test.go new file mode 100644 index 00000000..d25a039e --- /dev/null +++ b/internal/provider/resource_access_jaas_role_test.go @@ -0,0 +1,120 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "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 role access +// verifying that users, service accounts and roles +// can access a role. More extensive tests for +// generic jaas access are available in +// resource_access_jaas_model_test.go + +func TestAcc_ResourceJaasAccessRole(t *testing.T) { + OnlyTestAgainstJAAS(t) + // Resource names, note that role two has access to role one. + RoleAccessResourceName := "juju_jaas_access_role.test" + + roleOneResourceName := "juju_jaas_role.test" + roleTwoResourceName := "juju_jaas_role.roleWithAccess" + accessSuccess := "assignee" + accessFail := "bogus" + user := "foo@domain.com" + roleOneName := acctest.RandomWithPrefix("role1") + roleTwoName := acctest.RandomWithPrefix("role2") + svcAcc := "test" + svcAccWithDomain := svcAcc + "@serviceaccount" + + // Objects for checking access + RoleRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() } + roleOneCheck := newCheckAttribute(roleOneResourceName, "uuid", RoleRelationF) + RoleWithMemberRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() + "#assignee" } + roleTwoCheck := newCheckAttribute(roleTwoResourceName, "uuid", RoleWithMemberRelationF) + UserTag := names.NewUserTag(user).String() + svcAccTag := names.NewUserTag(svcAccWithDomain).String() + + // Test 0: Test an invalid access string. + // Test 1: Test adding a valid set user, role 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, roleOneCheck.tag, false), + testAccCheckJaasResourceAccess(accessSuccess, roleTwoCheck.tag, roleOneCheck.tag, false), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, roleOneCheck.tag, false), + ), + Steps: []resource.TestStep{ + { + Config: testAccResourceJaasAccessRole(roleOneName, accessFail, user, roleTwoName, svcAcc), + ExpectError: regexp.MustCompile(fmt.Sprintf("(?s)unknown.*relation %s", accessFail)), + }, + { + Config: testAccResourceJaasAccessRole(roleOneName, accessSuccess, user, roleTwoName, svcAcc), + Check: resource.ComposeTestCheckFunc( + testAccCheckAttributeNotEmpty(roleOneCheck), + testAccCheckAttributeNotEmpty(roleTwoCheck), + testAccCheckJaasResourceAccess(accessSuccess, &UserTag, roleOneCheck.tag, true), + testAccCheckJaasResourceAccess(accessSuccess, roleTwoCheck.tag, roleOneCheck.tag, true), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, roleOneCheck.tag, true), + resource.TestCheckResourceAttr(RoleAccessResourceName, "access", accessSuccess), + resource.TestCheckTypeSetElemAttr(RoleAccessResourceName, "users.*", user), + resource.TestCheckResourceAttr(RoleAccessResourceName, "users.#", "1"), + // Wrap this check so that the pointer has deferred evaluation. + func(s *terraform.State) error { + return resource.TestCheckTypeSetElemAttr(RoleAccessResourceName, "roles.*", *roleTwoCheck.resourceID)(s) + }, + resource.TestCheckResourceAttr(RoleAccessResourceName, "roles.#", "1"), + resource.TestCheckTypeSetElemAttr(RoleAccessResourceName, "service_accounts.*", svcAcc), + resource.TestCheckResourceAttr(RoleAccessResourceName, "service_accounts.#", "1"), + ), + }, + { + ImportStateVerify: true, + ImportState: true, + ResourceName: RoleAccessResourceName, + }, + }, + }) +} + +func testAccResourceJaasAccessRole(roleName, access, user, roleWithAccess, svcAcc string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceJaasAccessRole", + ` +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + +resource "juju_jaas_role" "roleWithAccess" { + name = "{{ .RoleWithAccess }}" +} + +resource "juju_jaas_access_role" "test" { + role_id = juju_jaas_role.test.uuid + access = "{{.Access}}" + users = ["{{.User}}"] + service_accounts = ["{{.SvcAcc}}"] +} +`, internaltesting.TemplateData{ + "Role": roleName, + "Access": access, + "User": user, + "RoleWithAccess": roleWithAccess, + "SvcAcc": svcAcc, + }) +} diff --git a/internal/provider/resource_access_jaas_service_account.go b/internal/provider/resource_access_jaas_service_account.go index 391d5051..962d3351 100644 --- a/internal/provider/resource_access_jaas_service_account.go +++ b/internal/provider/resource_access_jaas_service_account.go @@ -98,6 +98,7 @@ type jaasAccessServiceAccountResourceServiceAccount struct { Users types.Set `tfsdk:"users"` ServiceAccounts types.Set `tfsdk:"service_accounts"` Groups types.Set `tfsdk:"groups"` + Roles types.Set `tfsdk:"roles"` Access types.String `tfsdk:"access"` // ID required for imports @@ -111,7 +112,7 @@ func (a *jaasAccessServiceAccountResource) Metadata(_ context.Context, req resou // Schema defines the schema for the JAAS serviceAccount access resource. func (a *jaasAccessServiceAccountResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - attributes := a.partialAccessSchema() + attributes := a.baseAccessSchema() attributes["service_account_id"] = schema.StringAttribute{ Description: "The ID of the service account for access management. If this is changed the resource will be deleted and a new resource will be created.", Required: true, diff --git a/internal/provider/resource_access_jaas_service_account_test.go b/internal/provider/resource_access_jaas_service_account_test.go index f2aa0d29..f292ab4b 100644 --- a/internal/provider/resource_access_jaas_service_account_test.go +++ b/internal/provider/resource_access_jaas_service_account_test.go @@ -28,16 +28,20 @@ func TestAcc_ResourceJaasAccessServiceAccount(t *testing.T) { // Resource names svcAccAccessResourceName := "juju_jaas_access_service_account.test" groupResourcename := "juju_jaas_group.test" + roleResourcename := "juju_jaas_role.test" accessSuccess := "administrator" accessFail := "bogus" user := "foo@domain.com" group := acctest.RandomWithPrefix("myGroup") + role := acctest.RandomWithPrefix("myRole") 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) + roleRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() + "#assignee" } + roleCheck := newCheckAttribute(roleResourcename, "uuid", roleRelationF) userTag := names.NewUserTag(user).String() svcAccTag := names.NewUserTag(svcAccWithDomain).String() targetSvcAccTag := jimmnames.NewServiceAccountTag("foo@serviceaccount").String() @@ -52,17 +56,19 @@ func TestAcc_ResourceJaasAccessServiceAccount(t *testing.T) { CheckDestroy: resource.ComposeAggregateTestCheckFunc( testAccCheckJaasResourceAccess(accessSuccess, &userTag, &targetSvcAccTag, false), testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, &targetSvcAccTag, false), + testAccCheckJaasResourceAccess(accessSuccess, roleCheck.tag, &targetSvcAccTag, false), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, &targetSvcAccTag, false), ), Steps: []resource.TestStep{ { - Config: testAccResourceJaasAccessServiceAccount(accessFail, user, group, svcAcc), + Config: testAccResourceJaasAccessServiceAccount(accessFail, user, group, svcAcc, role), ExpectError: regexp.MustCompile(fmt.Sprintf("(?s)unknown.*relation %s", accessFail)), }, { - Config: testAccResourceJaasAccessServiceAccount(accessSuccess, user, group, svcAcc), + Config: testAccResourceJaasAccessServiceAccount(accessSuccess, user, group, svcAcc, role), Check: resource.ComposeTestCheckFunc( testAccCheckAttributeNotEmpty(groupCheck), + testAccCheckAttributeNotEmpty(roleCheck), testAccCheckJaasResourceAccess(accessSuccess, &userTag, &targetSvcAccTag, true), testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, &targetSvcAccTag, true), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, &targetSvcAccTag, true), @@ -87,10 +93,14 @@ func TestAcc_ResourceJaasAccessServiceAccount(t *testing.T) { }) } -func testAccResourceJaasAccessServiceAccount(access, user, group, svcAcc string) string { +func testAccResourceJaasAccessServiceAccount(access, user, group, svcAcc, role string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceJaasAccessServiceAccount", ` +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + resource "juju_jaas_group" "test" { name = "{{ .Group }}" } @@ -100,6 +110,7 @@ resource "juju_jaas_access_service_account" "test" { access = "{{.Access}}" users = ["{{.User}}"] groups = [juju_jaas_group.test.uuid] + roles = [juju_jaas_role.test.uuid] service_accounts = ["{{.SvcAcc}}"] } `, internaltesting.TemplateData{ @@ -107,5 +118,6 @@ resource "juju_jaas_access_service_account" "test" { "User": user, "Group": group, "SvcAcc": svcAcc, + "Role": role, }) } diff --git a/internal/provider/resource_jaas_role.go b/internal/provider/resource_jaas_role.go new file mode 100644 index 00000000..c32ef107 --- /dev/null +++ b/internal/provider/resource_jaas_role.go @@ -0,0 +1,207 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "context" + "fmt" + + "github.com/canonical/jimm-go-sdk/v3/names" + "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-log/tflog" + + "github.com/juju/terraform-provider-juju/internal/juju" +) + +var _ resource.Resource = &jaasRoleResource{} +var _ resource.ResourceWithConfigure = &jaasRoleResource{} + +type jaasRoleResource struct { + client *juju.Client + + // subCtx is the context created with the new tflog subsystem for applications. + subCtx context.Context +} + +// NewJAASRoleResource returns a new instance of the JAAS role resource. +func NewJAASRoleResource() resource.Resource { + return &jaasRoleResource{} +} + +type jaasRoleResourceModel struct { + Name types.String `tfsdk:"name"` + UUID types.String `tfsdk:"uuid"` +} + +// Metadata returns the metadata for the JAAS role resource. +func (r *jaasRoleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_jaas_role" +} + +// Schema defines the schema for JAAS roles. +func (r *jaasRoleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A resource that represents a role in JAAS", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Name of the role", + Required: true, + Validators: []validator.String{ + ValidatorMatchString( + names.IsValidRoleName, + "must start with a letter, end with a letter or number, and contain only letters, numbers, periods, underscores, and hyphens", + ), + }, + }, + "uuid": schema.StringAttribute{ + Description: "UUID of the role", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Configure sets up the JAAS role resource with the provider data. +func (resource *jaasRoleResource) 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 + } + resource.client = client + // Create the local logging subsystem here, using the TF context when creating it. + resource.subCtx = tflog.NewSubsystem(ctx, LogResourceJAASRole) +} + +// Create attempts to create the role represented by the resource in JAAS. +func (resource *jaasRoleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Check first if the client is configured + if resource.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, LogResourceJAASRole, "create") + return + } + + // Read Terraform configuration from the request into the model + var plan jaasRoleResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Add the role to JAAS + uuid, err := resource.client.Jaas.AddRole(plan.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add role %q, got error: %s", plan.Name.ValueString(), err)) + return + } + + // Set the UUID in the state + plan.UUID = types.StringValue(uuid) + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +// Read attempts to read the role represented by the resource from JAAS. +func (resource *jaasRoleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Check first if the client is configured + if resource.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, LogResourceJAASRole, "read") + return + } + + // Read the Terraform state from the request into the model + var state jaasRoleResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Read the role from JAAS + role, err := resource.client.Jaas.ReadRoleByUUID(state.UUID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get role %q, got error: %s", state.Name.ValueString(), err)) + return + } + + // Set the role name in the state + state.Name = types.StringValue(role.Name) + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +// Update attempts to rename the role represented by the resource in JAAS. +func (resource *jaasRoleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Check first if the client is configured + if resource.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, LogResourceJAASRole, "update") + return + } + + // Read the current state from the request + var state jaasRoleResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Read the plan from the request into the model + var plan jaasRoleResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // If the name has not changed, there is nothing to do + if state.Name.ValueString() == plan.Name.ValueString() { + return + } + + // Rename the role in JAAS + err := resource.client.Jaas.RenameRole(state.Name.ValueString(), plan.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to rename role %q to %q, got error: %s", state.Name.ValueString(), plan.Name.ValueString(), err)) + return + } + + // Update the state with the new name + state.Name = plan.Name + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +// Delete attempts to remove the role represented by the resource from JAAS. +func (resource *jaasRoleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Check first if the client is configured + if resource.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, LogResourceJAASRole, "delete") + return + } + + // Read the Terraform state from the request into the model + var state jaasRoleResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Delete the role from JAAS + err := resource.client.Jaas.RemoveRole(state.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to remove role %q, got error: %s", state.Name.ValueString(), err)) + return + } +} diff --git a/internal/provider/resource_jaas_role_test.go b/internal/provider/resource_jaas_role_test.go new file mode 100644 index 00000000..86f96437 --- /dev/null +++ b/internal/provider/resource_jaas_role_test.go @@ -0,0 +1,89 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "errors" + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + internaltesting "github.com/juju/terraform-provider-juju/internal/testing" +) + +func TestAcc_ResourceJaasRole(t *testing.T) { + OnlyTestAgainstJAAS(t) + roleName := acctest.RandomWithPrefix("tf-jaas-role") + newRoleName := acctest.RandomWithPrefix("tf-jaas-role-new") + resourceName := "juju_jaas_role.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + CheckDestroy: testAccCheckJaasRoleExists(resourceName, false), + Steps: []resource.TestStep{ + { + Config: testAccResourceJaasRole(roleName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", roleName), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + testAccCheckJaasRoleExists(resourceName, true), + ), + }, + { + Config: testAccResourceJaasRole("_invalid role"), + // Might break if the formatting changes + ExpectError: regexp.MustCompile("must start with a letter, end with a letter or number"), + }, + { + Config: testAccResourceJaasRole(newRoleName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", newRoleName), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + testAccCheckJaasRoleExists(resourceName, true), + ), + }, + }, + }) +} + +func testAccResourceJaasRole(name string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceJaasRole", + ` +resource "juju_jaas_role" "test" { + name = "{{ .Name }}" +} +`, internaltesting.TemplateData{ + "Name": name, + }) +} + +// testAccCheckJaasRoleExists returns a function that checks if the role exists if checkExists is true or if it doesn't exist if checkExists is false. +func testAccCheckJaasRoleExists(resourceName string, checkExists bool) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Role %q not found", resourceName) + } + + uuid := rs.Primary.Attributes["uuid"] + if uuid == "" { + return errors.New("No role uuid is set") + } + + _, err := TestClient.Jaas.ReadRoleByUUID(uuid) + if checkExists && err != nil { + return fmt.Errorf("Role with uuid %q does not exist", uuid) + } else if !checkExists && err == nil { + return fmt.Errorf("Role with uuid %q still exists", uuid) + } + + return nil + } +}