diff --git a/docker-compose.yml b/docker-compose.yml index 18a454747..ab4071db6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: - GF_SERVER_ROOT_URL=${GRAFANA_URL} - GF_ENTERPRISE_LICENSE_TEXT=${GF_ENTERPRISE_LICENSE_TEXT:-} - GF_SERVER_SERVE_FROM_SUB_PATH=${GF_SERVER_SERVE_FROM_SUB_PATH:-} - - GF_FEATURE_TOGGLES_ENABLE=nestedFolders + - GF_FEATURE_TOGGLES_ENABLE=nestedFolders,groupAttributeSync healthcheck: test: wget --no-verbose --tries=1 --spider http://0.0.0.0:3000/api/health || exit 1 # Use wget because older versions of Grafana don't have curl interval: 10s diff --git a/docs/resources/group_attribute_mapping.md b/docs/resources/group_attribute_mapping.md new file mode 100644 index 000000000..773036ec7 --- /dev/null +++ b/docs/resources/group_attribute_mapping.md @@ -0,0 +1,64 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_group_attribute_mapping Resource - terraform-provider-grafana" +subcategory: "Grafana Enterprise" +description: |- + Group attribute mapping is used to map groups from an external identity provider to Grafana attributes. This resource maps groups to fixed and custom role-based access control roles. + !> Warning: The resource is experimental and will be subject to change. To use the resource, you need to enable groupAttributeSync feature flag. + This resource requires Grafana Enterprise or Cloud >=11.4.0. +--- + +# grafana_group_attribute_mapping (Resource) + +Group attribute mapping is used to map groups from an external identity provider to Grafana attributes. This resource maps groups to fixed and custom role-based access control roles. + +!> Warning: The resource is experimental and will be subject to change. To use the resource, you need to enable groupAttributeSync feature flag. + +This resource requires Grafana Enterprise or Cloud >=11.4.0. + +## Example Usage + +```terraform +resource "grafana_role" "report_admin_role" { + name = "Report Administrator" + uid = "report_admin_role_uid" + auto_increment_version = true + permissions { + action = "reports:create" + } + permissions { + action = "reports:read" + scope = "reports:*" + } +} + +resource "grafana_group_attribute_mapping" "report_admin_mapping" { + group_id = "business_dev_group_id" + role_uids = [grafana_role.report_admin_role.uid] +} +``` + + +## Schema + +### Required + +- `group_id` (String) Group ID from the identity provider. +- `role_uids` (Set of String) Fixed or custom Grafana role-based access control role UIDs. + +### Optional + +- `org_id` (String) The Organization ID. If not set, the default organization is used for basic authentication, or the one that owns your service account for token authentication. + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import grafana_group_attribute_mapping.name "{{ group_id }}" +terraform import grafana_group_attribute_mapping.name "{{ orgID }}:{{ group_id }}" +``` diff --git a/examples/resources/grafana_group_attribute_mapping/import.sh b/examples/resources/grafana_group_attribute_mapping/import.sh new file mode 100644 index 000000000..a84ad80ce --- /dev/null +++ b/examples/resources/grafana_group_attribute_mapping/import.sh @@ -0,0 +1,2 @@ +terraform import grafana_group_attribute_mapping.name "{{ group_id }}" +terraform import grafana_group_attribute_mapping.name "{{ orgID }}:{{ group_id }}" diff --git a/examples/resources/grafana_group_attribute_mapping/resource.tf b/examples/resources/grafana_group_attribute_mapping/resource.tf new file mode 100644 index 000000000..41256d3ec --- /dev/null +++ b/examples/resources/grafana_group_attribute_mapping/resource.tf @@ -0,0 +1,17 @@ +resource "grafana_role" "report_admin_role" { + name = "Report Administrator" + uid = "report_admin_role_uid" + auto_increment_version = true + permissions { + action = "reports:create" + } + permissions { + action = "reports:read" + scope = "reports:*" + } +} + +resource "grafana_group_attribute_mapping" "report_admin_mapping" { + group_id = "business_dev_group_id" + role_uids = [grafana_role.report_admin_role.uid] +} diff --git a/internal/resources/grafana/common_check_exists_test.go b/internal/resources/grafana/common_check_exists_test.go index f4066c1b7..2dcffd47a 100644 --- a/internal/resources/grafana/common_check_exists_test.go +++ b/internal/resources/grafana/common_check_exists_test.go @@ -255,6 +255,21 @@ var ( return payloadOrError(resp, err) }, ) + groupAttrMappingCheckExists = newCheckExistsHelper( + func(g *models.Group) string { return g.GroupID }, + func(client *goapi.GrafanaHTTPAPI, id string) (*models.Group, error) { + resp, err := client.GroupAttributeSync.GetMappedGroups() + if err != nil { + return nil, err + } + for _, group := range resp.Payload.Groups { + if group.GroupID == id { + return group, nil + } + } + return nil, &runtime.APIError{Code: 404, Response: "mapping not found"} + }, + ) ) type checkExistsGetResourceFunc[T interface{}] func(client *goapi.GrafanaHTTPAPI, id string) (*T, error) diff --git a/internal/resources/grafana/resource_group_attribute_mapping.go b/internal/resources/grafana/resource_group_attribute_mapping.go new file mode 100644 index 000000000..4b2585a50 --- /dev/null +++ b/internal/resources/grafana/resource_group_attribute_mapping.go @@ -0,0 +1,245 @@ +package grafana + +import ( + "context" + "strconv" + + goapi "github.com/grafana/grafana-openapi-client-go/client" + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.ResourceWithImportState = (*resourceGroupAttributeMapping)(nil) + + resourceGroupAttributeMappingName = "grafana_group_attribute_mapping" + resourceGroupAttributeMappingID = common.NewResourceID(common.OptionalIntIDField("orgID"), common.StringIDField("group_id")) +) + +func makeResourceGroupAttributeMapping() *common.Resource { + return common.NewResource( + common.CategoryGrafanaEnterprise, + resourceGroupAttributeMappingName, + resourceGroupAttributeMappingID, + &resourceGroupAttributeMapping{}, + ).WithLister(listerFunctionOrgResource(listGroupAttributeMappings)) +} + +type resourceGroupAttributeMappingModel struct { + ID types.String `tfsdk:"id"` + OrgID types.String `tfsdk:"org_id"` + GroupID types.String `tfsdk:"group_id"` + RoleUIDs types.Set `tfsdk:"role_uids"` +} + +type resourceGroupAttributeMapping struct { + basePluginFrameworkResource +} + +func (r *resourceGroupAttributeMapping) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = resourceGroupAttributeMappingName +} + +func (r *resourceGroupAttributeMapping) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: ` +Group attribute mapping is used to map groups from an external identity provider to Grafana attributes. This resource maps groups to fixed and custom role-based access control roles. + +!> Warning: The resource is experimental and will be subject to change. To use the resource, you need to enable groupAttributeSync feature flag. + +This resource requires Grafana Enterprise or Cloud >=11.4.0. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "org_id": pluginFrameworkOrgIDAttribute(), + "group_id": schema.StringAttribute{ + Required: true, + Description: "Group ID from the identity provider.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "role_uids": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + Description: "Fixed or custom Grafana role-based access control role UIDs.", + }, + }, + } +} + +func (r *resourceGroupAttributeMapping) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data resourceGroupAttributeMappingModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + client, orgID, err := r.clientFromNewOrgResource(data.OrgID.ValueString()) + if err != nil { + resp.Diagnostics = diag.Diagnostics{diag.NewErrorDiagnostic("Failed to get client", err.Error())} + return + } + + roles := make([]string, 0, len(data.RoleUIDs.Elements())) + for _, roleUID := range data.RoleUIDs.Elements() { + roles = append(roles, roleUID.String()) + } + + _, err = client.GroupAttributeSync.CreateGroupMappings(data.GroupID.ValueString(), &models.GroupAttributes{Roles: roles}) + if err != nil { + resp.Diagnostics.AddError("Failed to create group attribute mapping", err.Error()) + return + } + + data.ID = types.StringValue(resourceGroupAttributeMappingID.Make(orgID, data.GroupID.ValueString())) + data.OrgID = types.StringValue(strconv.FormatInt(orgID, 10)) + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} + +func (r *resourceGroupAttributeMapping) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data resourceGroupAttributeMappingModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + readData, diags := r.read(ctx, data.ID.ValueString()) + if diags != nil { + resp.Diagnostics = diags + return + } + if readData == nil { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, readData)...) +} + +func (r *resourceGroupAttributeMapping) read(ctx context.Context, id string) (*resourceGroupAttributeMappingModel, diag.Diagnostics) { + client, orgID, idFields, err := r.clientFromExistingOrgResource(resourceGroupAttributeMappingID, id) + if err != nil { + return nil, diag.Diagnostics{diag.NewErrorDiagnostic("Failed to get client", err.Error())} + } + groupID := idFields[0].(string) + + resp, err := client.GroupAttributeSync.GetGroupRoles(groupID) + if err != nil { + return nil, diag.Diagnostics{diag.NewErrorDiagnostic("Failed to get group mappings", err.Error())} + } + // No roles are returned if no mappings are found. + if len(resp.Payload) == 0 { + return nil, nil + } + + data := &resourceGroupAttributeMappingModel{ + ID: types.StringValue(id), + OrgID: types.StringValue(strconv.FormatInt(orgID, 10)), + GroupID: types.StringValue(groupID), + } + + uids := make([]types.String, 0, len(resp.Payload)) + for _, role := range resp.Payload { + uids = append(uids, types.StringValue(role.UID)) + } + + roleUIDs, diags := types.SetValueFrom(ctx, types.StringType, uids) + if diags.HasError() { + return nil, diags + } + + data.RoleUIDs = roleUIDs + return data, nil +} + +func (r *resourceGroupAttributeMapping) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data resourceGroupAttributeMappingModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + client, _, _, err := r.clientFromExistingOrgResource(resourceGroupAttributeMappingID, data.ID.ValueString()) + if err != nil { + resp.Diagnostics = diag.Diagnostics{diag.NewErrorDiagnostic("Failed to get client", err.Error())} + return + } + + roles := make([]string, 0, len(data.RoleUIDs.Elements())) + for _, roleUID := range data.RoleUIDs.Elements() { + roles = append(roles, roleUID.String()) + } + + _, err = client.GroupAttributeSync.UpdateGroupMappings(data.GroupID.ValueString(), &models.GroupAttributes{Roles: roles}) + if err != nil { + resp.Diagnostics.AddError("Failed to create group attribute mapping", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} + +func (r *resourceGroupAttributeMapping) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data resourceGroupAttributeMappingModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + client, _, idFields, err := r.clientFromExistingOrgResource(resourceGroupAttributeMappingID, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to get client", err.Error()) + return + } + + groupID := idFields[0].(string) + + if _, err := client.GroupAttributeSync.DeleteGroupMappings(groupID); err != nil { + resp.Diagnostics.AddError("Unable to delete group mappings", err.Error()) + } +} + +func (r *resourceGroupAttributeMapping) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + data, diags := r.read(ctx, req.ID) + if diags != nil { + resp.Diagnostics = diags + return + } + if data == nil { + resp.Diagnostics.AddError("Group mapping not found", "Group mapping not found") + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} + +func listGroupAttributeMappings(ctx context.Context, client *goapi.GrafanaHTTPAPI, orgID int64) ([]string, error) { + var ids []string + var page int64 = 1 + for { + resp, err := client.GroupAttributeSync.GetMappedGroups() + if err != nil { + return nil, err + } + for _, g := range resp.GetPayload().Groups { + ids = append(ids, MakeOrgResourceID(orgID, g.GroupID)) + } + + if resp.Payload.Total <= int64(len(ids)) { + break + } + + page++ + } + + return ids, nil +} diff --git a/internal/resources/grafana/resource_group_attribute_mapping_test.go b/internal/resources/grafana/resource_group_attribute_mapping_test.go new file mode 100644 index 000000000..b23407b28 --- /dev/null +++ b/internal/resources/grafana/resource_group_attribute_mapping_test.go @@ -0,0 +1,96 @@ +package grafana_test + +import ( + "fmt" + "testing" + + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/grafana/terraform-provider-grafana/v3/internal/testutils" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGroupAttributeSync_basic(t *testing.T) { + testutils.CheckEnterpriseTestsEnabled(t, ">=11.4.0") + + var groupMapping models.Group + var role models.RoleDTO + + resource.ParallelTest(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: groupAttrMappingCheckExists.destroyed(&groupMapping, nil), + Steps: []resource.TestStep{ + { + // Can create a group attribute mapping with multiple roles + Config: testAccGroupAttributeSyncConfig("grafana_role.role_1.uid", "grafana_role.role_2.uid"), + Check: resource.ComposeAggregateTestCheckFunc( + groupAttrMappingCheckExists.exists("grafana_group_attribute_mapping.test", &groupMapping), + roleCheckExists.exists("grafana_role.role_1", &role), + roleCheckExists.exists("grafana_role.role_2", &role), + roleCheckExists.exists("grafana_role.role_3", &role), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "id", "1:test_group_id"), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "group_id", "test_group_id"), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "org_id", "1"), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "role_uids.#", "2"), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "role_uids.0", "role_1_uid"), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "role_uids.1", "role_2_uid"), + testutils.CheckLister("grafana_group_attribute_mapping.test"), + ), + }, + { + // Can update a group attribute mapping with multiple roles + Config: testAccGroupAttributeSyncConfig("grafana_role.role_1.uid", "grafana_role.role_3.uid"), + Check: resource.ComposeAggregateTestCheckFunc( + groupAttrMappingCheckExists.exists("grafana_group_attribute_mapping.test", &groupMapping), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "id", "1:test_group_id"), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "role_uids.#", "2"), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "role_uids.0", "role_1_uid"), + resource.TestCheckResourceAttr("grafana_group_attribute_mapping.test", "role_uids.1", "role_3_uid"), + ), + }, + { + // Can import a group attribute mapping + ImportState: true, + ResourceName: "grafana_group_attribute_mapping.test", + ImportStateVerify: true, + }, + }, + }) +} + +func testAccGroupAttributeSyncConfig(roleUID1, roleUID2 string) string { + return fmt.Sprintf(` +resource "grafana_role" "role_1" { + name = "role_1" + uid = "role_1_uid" + auto_increment_version = true + permissions { + action = "teams:read" + scope = "teams:*" + } +} + +resource "grafana_role" "role_2" { + name = "role_2" + uid = "role_2_uid" + auto_increment_version = true + permissions { + action = "teams:create" + } +} + +resource "grafana_role" "role_3" { + name = "role_3" + uid = "role_3_uid" + auto_increment_version = true + permissions { + action = "teams:write" + scope = "teams:*" + } +} + +resource "grafana_group_attribute_mapping" "test" { + group_id = "test_group_id" + role_uids = [%s, %s] +} +`, roleUID1, roleUID2) +} diff --git a/internal/resources/grafana/resources.go b/internal/resources/grafana/resources.go index 99cf44a5e..64a0739f9 100644 --- a/internal/resources/grafana/resources.go +++ b/internal/resources/grafana/resources.go @@ -105,6 +105,7 @@ var Resources = addValidationToResources( makeResourceFolderPermissionItem(), makeResourceDashboardPermissionItem(), makeResourceDatasourcePermissionItem(), + makeResourceGroupAttributeMapping(), makeResourceRoleAssignmentItem(), makeResourceServiceAccountPermissionItem(), resourceAnnotation(), diff --git a/pkg/generate/postprocessing/replace_references.go b/pkg/generate/postprocessing/replace_references.go index 46f1d9d03..2e2e7b4b5 100644 --- a/pkg/generate/postprocessing/replace_references.go +++ b/pkg/generate/postprocessing/replace_references.go @@ -63,6 +63,7 @@ var knownReferences = []string{ "grafana_folder_permission_item.team=grafana_team.id", "grafana_folder_permission_item.user=grafana_service_account.id", "grafana_folder_permission_item.user=grafana_user.id", + "grafana_group_attribute_mapping.role_uids=grafana_role.uid", "grafana_library_panel.folder_uid=grafana_folder.uid", "grafana_library_panel.org_id=grafana_organization.id", "grafana_machine_learning_alert.job_id=grafana_machine_learning_job.id",