diff --git a/.github/workflows/test_integration_jaas.yaml b/.github/workflows/test_integration_jaas.yaml index f22d746f..d234b0d0 100644 --- a/.github/workflows/test_integration_jaas.yaml +++ b/.github/workflows/test_integration_jaas.yaml @@ -64,7 +64,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/docs/data-sources/jaas_role.md b/docs/data-sources/jaas_role.md new file mode 100644 index 00000000..b77f419f --- /dev/null +++ b/docs/data-sources/jaas_role.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "juju_jaas_role Data Source - terraform-provider-juju" +subcategory: "" +description: |- + A data source representing a Juju JAAS Role. +--- + +# juju_jaas_role (Data Source) + +A data source representing a Juju JAAS Role. + +## Example Usage + +```terraform +data "juju_jaas_role" "test" { + name = "role-0" +} + +output "role_uuid" { + value = data.juju_jaas_role.test.uuid +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the role. + +### Read-Only + +- `uuid` (String) The UUID of the role. The UUID is used to reference roles in other resources. diff --git a/docs/resources/jaas_access_cloud.md b/docs/resources/jaas_access_cloud.md index 500747f6..3ed34730 100644 --- a/docs/resources/jaas_access_cloud.md +++ b/docs/resources/jaas_access_cloud.md @@ -33,6 +33,7 @@ resource "juju_jaas_access_cloud" "development" { ### Optional - `groups` (Set of String) List of groups to grant access. +- `roles` (Set of String) List of roles UUID to grant access. - `service_accounts` (Set of String) List of service accounts to grant access. - `users` (Set of String) List of users to grant access. diff --git a/docs/resources/jaas_access_controller.md b/docs/resources/jaas_access_controller.md index 7641e056..f0209ec0 100644 --- a/docs/resources/jaas_access_controller.md +++ b/docs/resources/jaas_access_controller.md @@ -31,6 +31,7 @@ resource "juju_jaas_access_controller" "development" { ### Optional - `groups` (Set of String) List of groups to grant access. +- `roles` (Set of String) List of roles UUID to grant access. - `service_accounts` (Set of String) List of service accounts to grant access. - `users` (Set of String) List of users to grant access. diff --git a/docs/resources/jaas_access_model.md b/docs/resources/jaas_access_model.md index f8ff7f49..a54543cd 100644 --- a/docs/resources/jaas_access_model.md +++ b/docs/resources/jaas_access_model.md @@ -33,6 +33,7 @@ resource "juju_jaas_access_model" "development" { ### Optional - `groups` (Set of String) List of groups to grant access. +- `roles` (Set of String) List of roles UUID to grant access. - `service_accounts` (Set of String) List of service accounts to grant access. - `users` (Set of String) List of users to grant access. diff --git a/docs/resources/jaas_access_offer.md b/docs/resources/jaas_access_offer.md index 40116027..8c9558a9 100644 --- a/docs/resources/jaas_access_offer.md +++ b/docs/resources/jaas_access_offer.md @@ -33,6 +33,7 @@ resource "juju_jaas_access_offer" "development" { ### Optional - `groups` (Set of String) List of groups to grant access. +- `roles` (Set of String) List of roles UUID to grant access. - `service_accounts` (Set of String) List of service accounts to grant access. - `users` (Set of String) List of users to grant access. diff --git a/docs/resources/jaas_access_role.md b/docs/resources/jaas_access_role.md new file mode 100644 index 00000000..435151d4 --- /dev/null +++ b/docs/resources/jaas_access_role.md @@ -0,0 +1,50 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "juju_jaas_access_role Resource - terraform-provider-juju" +subcategory: "" +description: |- + A resource that represents access to a role when using JAAS. +--- + +# juju_jaas_access_role (Resource) + +A resource that represents access to a role when using JAAS. + +## Example Usage + +```terraform +resource "juju_jaas_access_role" "development" { + role_id = juju_jaas_role.target-role.uuid + access = "assignee" + users = ["foo@domain.com"] + roles = [juju_jaas_role.development.uuid] + service_accounts = ["Client-ID-1", "Client-ID-2"] +} +``` + + +## Schema + +### Required + +- `access` (String) 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 +- `role_id` (String) The ID of the role for access management. If this is changed the resource will be deleted and a new resource will be created. + +### Optional + +- `groups` (Set of String) List of groups to grant access. +- `service_accounts` (Set of String) List of service accounts to grant access. +- `users` (Set of String) List of users to grant access. + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +# JAAS role access can be imported using the role UUID and access level +$ terraform import juju_jaas_access_role.development UUID:assignee +``` diff --git a/docs/resources/jaas_access_service_account.md b/docs/resources/jaas_access_service_account.md index 3c12525a..75aa39d7 100644 --- a/docs/resources/jaas_access_service_account.md +++ b/docs/resources/jaas_access_service_account.md @@ -33,6 +33,7 @@ resource "juju_jaas_access_service_account" "development" { ### Optional - `groups` (Set of String) List of groups to grant access. +- `roles` (Set of String) List of roles UUID to grant access. - `service_accounts` (Set of String) List of service accounts to grant access. - `users` (Set of String) List of users to grant access. diff --git a/docs/resources/jaas_role.md b/docs/resources/jaas_role.md new file mode 100644 index 00000000..b44ef849 --- /dev/null +++ b/docs/resources/jaas_role.md @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "juju_jaas_role Resource - terraform-provider-juju" +subcategory: "" +description: |- + A resource that represents a role in JAAS +--- + +# juju_jaas_role (Resource) + +A resource that represents a role in JAAS + +## Example Usage + +```terraform +resource "juju_jaas_role" "development" { + name = "devops-team" +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the role + +### Read-Only + +- `uuid` (String) UUID of the role diff --git a/examples/data-sources/juju_jaas_role/data-source.tf b/examples/data-sources/juju_jaas_role/data-source.tf new file mode 100644 index 00000000..bb896dad --- /dev/null +++ b/examples/data-sources/juju_jaas_role/data-source.tf @@ -0,0 +1,7 @@ +data "juju_jaas_role" "test" { + name = "role-0" +} + +output "role_uuid" { + value = data.juju_jaas_role.test.uuid +} diff --git a/examples/resources/juju_jaas_access_role/import.sh b/examples/resources/juju_jaas_access_role/import.sh new file mode 100644 index 00000000..e7f59a06 --- /dev/null +++ b/examples/resources/juju_jaas_access_role/import.sh @@ -0,0 +1,2 @@ +# JAAS role access can be imported using the role UUID and access level +$ terraform import juju_jaas_access_role.development UUID:assignee diff --git a/examples/resources/juju_jaas_access_role/resource.tf b/examples/resources/juju_jaas_access_role/resource.tf new file mode 100644 index 00000000..6234ad9e --- /dev/null +++ b/examples/resources/juju_jaas_access_role/resource.tf @@ -0,0 +1,7 @@ +resource "juju_jaas_access_role" "development" { + role_id = juju_jaas_role.target-role.uuid + access = "assignee" + users = ["foo@domain.com"] + roles = [juju_jaas_role.development.uuid] + service_accounts = ["Client-ID-1", "Client-ID-2"] +} diff --git a/examples/resources/juju_jaas_role/resource.tf b/examples/resources/juju_jaas_role/resource.tf new file mode 100644 index 00000000..3942d1b9 --- /dev/null +++ b/examples/resources/juju_jaas_role/resource.tf @@ -0,0 +1,3 @@ +resource "juju_jaas_role" "development" { + name = "devops-team" +} 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..550a3492 100644 --- a/internal/juju/jaas.go +++ b/internal/juju/jaas.go @@ -217,3 +217,78 @@ func (jc *jaasClient) RemoveGroup(name string) error { req := params.RemoveGroupRequest{Name: name} return client.RemoveGroup(&req) } + +// JaasRole represents a JAAS role used for permissions management. +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_group.go b/internal/provider/data_source_jaas_group.go index 462a839d..ddad77fb 100644 --- a/internal/provider/data_source_jaas_group.go +++ b/internal/provider/data_source_jaas_group.go @@ -91,7 +91,7 @@ func (d *jaasGroupDataSource) Read(ctx context.Context, req datasource.ReadReque } // Update the group with the latest data from JAAS - group, err := d.client.Jaas.ReadGroupByName(data.Name.String()) + group, err := d.client.Jaas.ReadGroupByName(data.Name.ValueString()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read group, got error: %v", err)) return diff --git a/internal/provider/data_source_jaas_role.go b/internal/provider/data_source_jaas_role.go new file mode 100644 index 00000000..031b7aaf --- /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. The UUID is used to reference roles in other resources.", + 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.ValueString()) + 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..a98ed260 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -34,14 +34,17 @@ const ( LogResourceAccessSecret = "resource-access-secret" LogDataSourceJAASGroup = "datasource-jaas-group" + LogDataSourceJAASRole = "datasource-jaas-role" LogResourceJAASAccessModel = "resource-jaas-access-model" LogResourceJAASAccessCloud = "resource-jaas-access-cloud" LogResourceJAASAccessGroup = "resource-jaas-access-group" + LogResourceJAASAccessRole = "resource-jaas-access-role" LogResourceJAASAccessOffer = "resource-jaas-access-offer" 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..0c6c991c 100644 --- a/internal/provider/resource_access_generic.go +++ b/internal/provider/resource_access_generic.go @@ -11,16 +11,10 @@ import ( jimmnames "github.com/canonical/jimm-go-sdk/v3/names" "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "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/hashicorp/terraform-plugin-log/tflog" @@ -49,8 +43,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) (genericJAASAccessData, names.Tag) - Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics + Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (objectsWithAccess, names.Tag) + Save(ctx context.Context, setter Setter, info objectsWithAccess, tag names.Tag) diag.Diagnostics ImportHint() string TagFromID(id string) (names.Tag, error) } @@ -68,13 +62,12 @@ type genericJAASAccessResource struct { subCtx context.Context } -// 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 genericJAASAccessData struct { +// objectsWithAccess holds all the objects that can have an access to a JAAS Resource. +type objectsWithAccess 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,68 +81,12 @@ 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. -// Access resources should use this schema and add any additional attributes e.g. name or uuid. -func (r *genericJAASAccessResource) partialAccessSchema() 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", - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "users": schema.SetAttribute{ - Description: "List of users to grant access.", - Optional: true, - ElementType: types.StringType, - Validators: []validator.Set{ - setvalidator.ValueStringsAre(ValidatorMatchString(names.IsValidUser, "email must be a valid Juju username")), - setvalidator.ValueStringsAre(stringvalidator.RegexMatches(basicEmailValidationRe, "email must contain an @ symbol")), - }, - }, - "groups": schema.SetAttribute{ - Description: "List of groups to grant access.", - Optional: true, - ElementType: types.StringType, - Validators: []validator.Set{ - setvalidator.ValueStringsAre(ValidatorMatchString(jimmnames.IsValidGroupId, "group ID must be valid")), - }, - }, - "service_accounts": schema.SetAttribute{ - Description: "List of service accounts to grant access.", - Optional: true, - ElementType: types.StringType, - // service accounts are treated as users but defined separately - // for different validation and logic in the provider. - Validators: []validator.Set{ - setvalidator.ValueStringsAre(ValidatorMatchString( - func(s string) bool { - // Use EnsureValidServiceAccountId instead of IsValidServiceAccountId - // because we avoid requiring the user to add @serviceaccount for service accounts - // and opt to add that in the provide code. EnsureValidServiceAccountId adds the - // @serviceaccount domain before verifying the string is a valid service account ID. - _, err := jimmnames.EnsureValidServiceAccountId(s) - return err == nil - }, "service account ID must be a valid Juju username")), - setvalidator.ValueStringsAre(stringvalidator.RegexMatches(avoidAtSymbolRe, "service account should not contain an @ symbol")), - }, - }, - // ID required for imports - "id": schema.StringAttribute{ - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - } -} - // Configure enables provider-level data or clients to be set in the // provider-defined DataSource type. It is separately executed for each // ReadDataSource RPC. @@ -242,6 +179,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)...) @@ -313,20 +251,24 @@ func (resource *genericJAASAccessResource) Update(ctx context.Context, req resou resp.Diagnostics.Append(resource.save(ctx, &resp.State, plan, targetTag)...) } -func diffModels(plan, state genericJAASAccessData, diag *diag.Diagnostics) (toAdd, toRemove genericJAASAccessData) { +func diffModels(plan, state objectsWithAccess, diag *diag.Diagnostics) (toAdd, toRemove objectsWithAccess) { 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 @@ -380,13 +322,17 @@ 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 - diag.Append(model.Users.ElementsAs(ctx, &users, false)...) - diag.Append(model.Groups.ElementsAs(ctx, &groups, false)...) - diag.Append(model.ServiceAccounts.ElementsAs(ctx, &serviceAccounts, false)...) +func modelToTuples(ctx context.Context, targetTag names.Tag, model objectsWithAccess, diag *diag.Diagnostics) []juju.JaasTuple { + var ( + users []string + groups []string + roles []string + serviceAccounts []string + ) + diag.Append(getSetIfKnown(ctx, model.Users, &users)...) + diag.Append(getSetIfKnown(ctx, model.Groups, &groups)...) + diag.Append(getSetIfKnown(ctx, model.Roles, &roles)...) + diag.Append(getSetIfKnown(ctx, model.ServiceAccounts, &serviceAccounts)...) if diag.HasError() { return []juju.JaasTuple{} } @@ -397,6 +343,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 +353,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 +func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diagnostics) objectsWithAccess { + var ( + users []string + groups []string + roles []string + serviceAccounts []string + ) for _, tuple := range tuples { tag, err := jimmnames.ParseTag(tuple.Object) if err != nil { @@ -437,17 +388,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 + var model objectsWithAccess model.Users = userSet model.Groups = groupSet + model.Roles = roleSet model.ServiceAccounts = serviceAccountSet return model } @@ -462,11 +418,19 @@ func assignTupleObject(baseTuple juju.JaasTuple, items []string, idToTag func(st return tuples } -func (a *genericJAASAccessResource) info(ctx context.Context, getter Getter, diags *diag.Diagnostics) (genericJAASAccessData, names.Tag) { +// getSetIfKnown populates the targetSlice if set is not null or unknown. +func getSetIfKnown(ctx context.Context, set types.Set, targetSlice *[]string) diag.Diagnostics { + if set.IsNull() || set.IsUnknown() { + return nil + } + return set.ElementsAs(ctx, targetSlice, false) +} + +func (a *genericJAASAccessResource) info(ctx context.Context, getter Getter, diags *diag.Diagnostics) (objectsWithAccess, names.Tag) { return a.targetResource.Info(ctx, getter, diags) } -func (a *genericJAASAccessResource) save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics { +func (a *genericJAASAccessResource) save(ctx context.Context, setter Setter, info objectsWithAccess, tag names.Tag) diag.Diagnostics { return a.targetResource.Save(ctx, setter, info, tag) } diff --git a/internal/provider/resource_access_generic_schema.go b/internal/provider/resource_access_generic_schema.go new file mode 100644 index 00000000..08f52598 --- /dev/null +++ b/internal/provider/resource_access_generic_schema.go @@ -0,0 +1,89 @@ +// Copyright 2025 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + jimmnames "github.com/canonical/jimm-go-sdk/v3/names" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/juju/names/v4" +) + +type genericSchema map[string]schema.Attribute + +// 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 baseAccessSchema() genericSchema { + 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", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "users": schema.SetAttribute{ + Description: "List of users to grant access.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(ValidatorMatchString(names.IsValidUser, "email must be a valid Juju username")), + setvalidator.ValueStringsAre(stringvalidator.RegexMatches(basicEmailValidationRe, "email must contain an @ symbol")), + }, + }, + "groups": schema.SetAttribute{ + Description: "List of groups to grant access.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(ValidatorMatchString(jimmnames.IsValidGroupId, "group ID must be valid")), + }, + }, + "service_accounts": schema.SetAttribute{ + Description: "List of service accounts to grant access.", + Optional: true, + ElementType: types.StringType, + // service accounts are treated as users but defined separately + // for different validation and logic in the provider. + Validators: []validator.Set{ + setvalidator.ValueStringsAre(ValidatorMatchString( + func(s string) bool { + // Use EnsureValidServiceAccountId instead of IsValidServiceAccountId + // because we avoid requiring the user to add @serviceaccount for service accounts + // and opt to add that in the provide code. EnsureValidServiceAccountId adds the + // @serviceaccount domain before verifying the string is a valid service account ID. + _, err := jimmnames.EnsureValidServiceAccountId(s) + return err == nil + }, "service account ID must be a valid Juju username")), + setvalidator.ValueStringsAre(stringvalidator.RegexMatches(avoidAtSymbolRe, "service account should not contain an @ symbol")), + }, + }, + // ID required for imports + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + } +} + +// WithRoles add roles to the schema +func (gS genericSchema) WithRoles() genericSchema { + gS["roles"] = schema.SetAttribute{ + Description: "List of roles UUID to grant access.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre(ValidatorMatchString(jimmnames.IsValidRoleId, "role ID must be valid")), + }, + } + return gS +} diff --git a/internal/provider/resource_access_jaas_cloud.go b/internal/provider/resource_access_jaas_cloud.go index 7ed50288..865c2911 100644 --- a/internal/provider/resource_access_jaas_cloud.go +++ b/internal/provider/resource_access_jaas_cloud.go @@ -35,13 +35,14 @@ func NewJAASAccessCloudResource() resource.Resource { 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) { +func (j cloudInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (objectsWithAccess, names.Tag) { cloudAccess := jaasAccessCloudResourceCloud{} diag.Append(getter.Get(ctx, &cloudAccess)...) - accessCloud := genericJAASAccessData{ + accessCloud := objectsWithAccess{ ID: cloudAccess.ID, Users: cloudAccess.Users, Groups: cloudAccess.Groups, + Roles: cloudAccess.Roles, ServiceAccounts: cloudAccess.ServiceAccounts, Access: cloudAccess.Access, } @@ -54,12 +55,13 @@ func (j cloudInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnosti } // 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 { +func (j cloudInfo) Save(ctx context.Context, setter Setter, info objectsWithAccess, tag names.Tag) diag.Diagnostics { cloudAccess := jaasAccessCloudResourceCloud{ CloudName: basetypes.NewStringValue(tag.Id()), ID: info.ID, Users: info.Users, Groups: info.Groups, + Roles: info.Roles, ServiceAccounts: info.ServiceAccounts, Access: info.Access, } @@ -89,6 +91,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 +105,8 @@ 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 := baseAccessSchema() + attributes = attributes.WithRoles() 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..78c49533 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("role1") 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,20 +61,23 @@ 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), + testAccCheckJaasResourceAccess(accessSuccess, roleCheck.tag, &cloudTag, true), resource.TestCheckResourceAttr(cloudAccessResourceName, "access", accessSuccess), resource.TestCheckTypeSetElemAttr(cloudAccessResourceName, "users.*", user), resource.TestCheckResourceAttr(cloudAccessResourceName, "users.#", "1"), @@ -138,10 +145,14 @@ func TestAcc_ResourceJaasAccessCloudImportState(t *testing.T) { }) } -func testAccResourceJaasAccessCloud(cloudName, access, user, group, svcAcc string) string { +func testAccResourceJaasAccessCloud(cloudName, access, user, group, svcAcc, role string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceJaasAccessCloud", ` +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + resource "juju_jaas_group" "test" { name = "{{ .Group }}" } @@ -151,6 +162,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 +170,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..367664b5 100644 --- a/internal/provider/resource_access_jaas_controller.go +++ b/internal/provider/resource_access_jaas_controller.go @@ -35,14 +35,14 @@ func NewJAASAccessControllerResource() resource.Resource { type controllerInfo struct{} // Info implements the [resourceInfo] interface, used to extract the info from a Terraform plan/state. -func (j controllerInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) { +func (j controllerInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (objectsWithAccess, names.Tag) { controllerAccess := jaasAccessControllerResourceController{} diag.Append(getter.Get(ctx, &controllerAccess)...) - return genericJAASAccessData(controllerAccess), names.NewControllerTag("jimm") + return objectsWithAccess(controllerAccess), names.NewControllerTag("jimm") } // Save implements the [resourceInfo] interface, used to save info on Terraform's state. -func (j controllerInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessData, _ names.Tag) diag.Diagnostics { +func (j controllerInfo) Save(ctx context.Context, setter Setter, info objectsWithAccess, _ names.Tag) diag.Diagnostics { return setter.Set(ctx, jaasAccessControllerResourceController(info)) } @@ -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,8 @@ 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 := baseAccessSchema() + attributes = attributes.WithRoles() // 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..6a042d85 100644 --- a/internal/provider/resource_access_jaas_controller_test.go +++ b/internal/provider/resource_access_jaas_controller_test.go @@ -30,16 +30,22 @@ 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("role1") 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,20 +60,23 @@ 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), + testAccCheckAttributeNotEmpty(roleCheck), testAccCheckJaasResourceAccess(accessSuccess, &userTag, &controllerTag, true), testAccCheckJaasResourceAccess(accessSuccess, groupCheck.tag, &controllerTag, true), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, &controllerTag, true), + testAccCheckJaasResourceAccess(accessSuccess, roleCheck.tag, &controllerTag, true), resource.TestCheckResourceAttr(controllerAccessResourceName, "access", accessSuccess), resource.TestCheckTypeSetElemAttr(controllerAccessResourceName, "users.*", user), resource.TestCheckResourceAttr(controllerAccessResourceName, "users.#", "1"), @@ -132,10 +141,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 +157,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..97aaf691 100644 --- a/internal/provider/resource_access_jaas_group.go +++ b/internal/provider/resource_access_jaas_group.go @@ -8,7 +8,9 @@ import ( "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" @@ -36,10 +38,10 @@ func NewJAASAccessGroupResource() resource.Resource { type groupInfo struct{} // Info implements the [resourceInfo] interface, used to extract the info from a Terraform plan/state. -func (j groupInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) { +func (j groupInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (objectsWithAccess, names.Tag) { groupAccess := jaasAccessModelResourceGroup{} diag.Append(getter.Get(ctx, &groupAccess)...) - accessGroup := genericJAASAccessData{ + accessGroup := objectsWithAccess{ ID: groupAccess.ID, Users: groupAccess.Users, Groups: groupAccess.Groups, @@ -55,7 +57,7 @@ func (j groupInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnosti } // Save implements the [resourceInfo] interface, used to save info on Terraform's state. -func (j groupInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics { +func (j groupInfo) Save(ctx context.Context, setter Setter, info objectsWithAccess, tag names.Tag) diag.Diagnostics { groupAccess := jaasAccessModelResourceGroup{ GroupID: basetypes.NewStringValue(tag.Id()), ID: info.ID, @@ -101,9 +103,21 @@ func (a *jaasAccessGroupResource) Metadata(_ context.Context, req resource.Metad resp.TypeName = req.ProviderTypeName + "_jaas_access_group" } +// ConfigValidators sets validators for the group resource. +func (r *jaasAccessGroupResource) 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 group access resource. func (a *jaasAccessGroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - attributes := a.partialAccessSchema() + attributes := 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_model.go b/internal/provider/resource_access_jaas_model.go index a0dabd0d..5082d884 100644 --- a/internal/provider/resource_access_jaas_model.go +++ b/internal/provider/resource_access_jaas_model.go @@ -35,13 +35,14 @@ 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) (genericJAASAccessData, names.Tag) { +func (j modelInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (objectsWithAccess, names.Tag) { modelAccess := jaasAccessModelResourceModel{} diag.Append(getter.Get(ctx, &modelAccess)...) - accessModel := genericJAASAccessData{ + accessModel := objectsWithAccess{ ID: modelAccess.ID, Users: modelAccess.Users, Groups: modelAccess.Groups, + Roles: modelAccess.Roles, ServiceAccounts: modelAccess.ServiceAccounts, Access: modelAccess.Access, } @@ -49,12 +50,13 @@ 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 genericJAASAccessData, tag names.Tag) diag.Diagnostics { +func (j modelInfo) Save(ctx context.Context, setter Setter, info objectsWithAccess, tag names.Tag) diag.Diagnostics { modelAccess := jaasAccessModelResourceModel{ ModelUUID: basetypes.NewStringValue(tag.Id()), ID: info.ID, Users: info.Users, Groups: info.Groups, + Roles: info.Roles, ServiceAccounts: info.ServiceAccounts, Access: info.Access, } @@ -84,6 +86,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 +100,8 @@ 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 := baseAccessSchema() + attributes = attributes.WithRoles() 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..2a581242 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("role1") // 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,17 +121,20 @@ 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), testAccCheckJaasResourceAccess(access, groupCheck.tag, modelCheck.tag, true), + testAccCheckJaasResourceAccess(access, roleCheck.tag, modelCheck.tag, true), resource.TestCheckResourceAttr(modelResourceName, "access", access), resource.TestCheckTypeSetElemAttr(modelResourceName, "users.*", user), resource.TestCheckResourceAttr(modelResourceName, "users.#", "1"), @@ -382,10 +389,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 +410,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 +419,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..128deaaf 100644 --- a/internal/provider/resource_access_jaas_offer.go +++ b/internal/provider/resource_access_jaas_offer.go @@ -35,13 +35,14 @@ func NewJAASAccessOfferResource() resource.Resource { type offerInfo struct{} // Info implements the [resourceInfo] interface, used to extract the info from a Terraform plan/state. -func (j offerInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) { +func (j offerInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (objectsWithAccess, names.Tag) { offerResource := jaasAccessOfferResourceOffer{} diag.Append(getter.Get(ctx, &offerResource)...) - genericInfo := genericJAASAccessData{ + genericInfo := objectsWithAccess{ ID: offerResource.ID, Users: offerResource.Users, Groups: offerResource.Groups, + Roles: offerResource.Roles, ServiceAccounts: offerResource.ServiceAccounts, Access: offerResource.Access, } @@ -54,12 +55,13 @@ func (j offerInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnosti } // Save implements the [resourceInfo] interface, used to save info on Terraform's state. -func (j offerInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics { +func (j offerInfo) Save(ctx context.Context, setter Setter, info objectsWithAccess, tag names.Tag) diag.Diagnostics { offerAccess := jaasAccessOfferResourceOffer{ OfferUrl: basetypes.NewStringValue(tag.Id()), ID: info.ID, Users: info.Users, Groups: info.Groups, + Roles: info.Roles, ServiceAccounts: info.ServiceAccounts, Access: info.Access, } @@ -86,6 +88,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 +102,8 @@ 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 := baseAccessSchema() + attributes = attributes.WithRoles() 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..9a191b05 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("role1") 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,21 +58,24 @@ 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), testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, offerCheck.tag, true), + testAccCheckJaasResourceAccess(accessSuccess, roleCheck.tag, offerCheck.tag, true), resource.TestCheckResourceAttr(offerAccessResourceName, "access", accessSuccess), resource.TestCheckTypeSetElemAttr(offerAccessResourceName, "users.*", user), resource.TestCheckResourceAttr(offerAccessResourceName, "users.#", "1"), @@ -90,7 +97,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 +121,10 @@ resource "juju_offer" "offerone" { endpoint = "sink" } +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + resource "juju_jaas_group" "test" { name = "{{ .Group }}" } @@ -123,6 +134,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 +143,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..30dde34f --- /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: LogResourceJAASAccessRole, + }} +} + +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) (objectsWithAccess, names.Tag) { + roleAccess := jaasAccessModelResourceRole{} + diag.Append(getter.Get(ctx, &roleAccess)...) + accessGroup := objectsWithAccess{ + 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 objectsWithAccess, 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:"groups"` + 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 := 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..cc9b617f --- /dev/null +++ b/internal/provider/resource_access_jaas_role_test.go @@ -0,0 +1,102 @@ +// 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/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" + accessSuccess := "assignee" + accessFail := "bogus" + user := "foo@domain.com" + roleOneName := acctest.RandomWithPrefix("role1") + svcAcc := "test" + svcAccWithDomain := svcAcc + "@serviceaccount" + + // Objects for checking access + RoleRelationF := func(s string) string { return jimmnames.NewRoleTag(s).String() } + roleOneCheck := newCheckAttribute(roleOneResourceName, "uuid", RoleRelationF) + 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, &svcAccTag, roleOneCheck.tag, false), + ), + Steps: []resource.TestStep{ + { + Config: testAccResourceJaasAccessRole(roleOneName, accessFail, user, svcAcc), + ExpectError: regexp.MustCompile(fmt.Sprintf("(?s)unknown.*relation %s", accessFail)), + }, + { + Config: testAccResourceJaasAccessRole(roleOneName, accessSuccess, user, svcAcc), + Check: resource.ComposeTestCheckFunc( + testAccCheckAttributeNotEmpty(roleOneCheck), + testAccCheckJaasResourceAccess(accessSuccess, &UserTag, roleOneCheck.tag, true), + testAccCheckJaasResourceAccess(accessSuccess, &svcAccTag, roleOneCheck.tag, true), + resource.TestCheckResourceAttr(RoleAccessResourceName, "access", accessSuccess), + resource.TestCheckTypeSetElemAttr(RoleAccessResourceName, "users.*", user), + resource.TestCheckResourceAttr(RoleAccessResourceName, "users.#", "1"), + resource.TestCheckTypeSetElemAttr(RoleAccessResourceName, "service_accounts.*", svcAcc), + resource.TestCheckResourceAttr(RoleAccessResourceName, "service_accounts.#", "1"), + ), + }, + { + ImportStateVerify: true, + ImportState: true, + ResourceName: RoleAccessResourceName, + }, + }, + }) +} + +func testAccResourceJaasAccessRole(roleName, access, user, svcAcc string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceJaasAccessRole", + ` +resource "juju_jaas_role" "test" { + name = "{{ .Role }}" +} + +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, + "SvcAcc": svcAcc, + }) +} diff --git a/internal/provider/resource_access_jaas_service_account.go b/internal/provider/resource_access_jaas_service_account.go index 391d5051..6476b6fb 100644 --- a/internal/provider/resource_access_jaas_service_account.go +++ b/internal/provider/resource_access_jaas_service_account.go @@ -37,13 +37,14 @@ func NewJAASAccessServiceAccountResource() resource.Resource { type serviceAccountInfo struct{} // Info implements the [resourceInfo] interface, used to extract the info from a Terraform plan/state. -func (j serviceAccountInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) { +func (j serviceAccountInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (objectsWithAccess, names.Tag) { serviceAccountAccess := jaasAccessServiceAccountResourceServiceAccount{} diag.Append(getter.Get(ctx, &serviceAccountAccess)...) - accessServiceAccount := genericJAASAccessData{ + accessServiceAccount := objectsWithAccess{ ID: serviceAccountAccess.ID, Users: serviceAccountAccess.Users, Groups: serviceAccountAccess.Groups, + Roles: serviceAccountAccess.Roles, ServiceAccounts: serviceAccountAccess.ServiceAccounts, Access: serviceAccountAccess.Access, } @@ -53,7 +54,7 @@ func (j serviceAccountInfo) Info(ctx context.Context, getter Getter, diag *diag. svcAccID, err := jimmnames.EnsureValidServiceAccountId(serviceAccountAccess.ServiceAccountID.ValueString()) if err != nil { diag.AddError("invalid service account name", err.Error()) - return genericJAASAccessData{}, nil + return objectsWithAccess{}, nil } tag = jimmnames.NewServiceAccountTag(svcAccID) } @@ -61,7 +62,7 @@ func (j serviceAccountInfo) Info(ctx context.Context, getter Getter, diag *diag. } // Save implements the [resourceInfo] interface, used to save info on Terraform's state. -func (j serviceAccountInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics { +func (j serviceAccountInfo) Save(ctx context.Context, setter Setter, info objectsWithAccess, tag names.Tag) diag.Diagnostics { // Do the reverse of what we did in Info and strip the @serviceaccount suffix. svcAccID := strings.TrimSuffix(tag.Id(), "@serviceaccount") serviceAccountAccess := jaasAccessServiceAccountResourceServiceAccount{ @@ -69,6 +70,7 @@ func (j serviceAccountInfo) Save(ctx context.Context, setter Setter, info generi ID: info.ID, Users: info.Users, Groups: info.Groups, + Roles: info.Roles, ServiceAccounts: info.ServiceAccounts, Access: info.Access, } @@ -98,6 +100,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 +114,8 @@ 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 := baseAccessSchema() + attributes = attributes.WithRoles() 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..336f8bd6 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("role1") 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 + } +}