Skip to content

Commit

Permalink
Ease of use: Add validation that org_id isn't used with API Keys/SA T…
Browse files Browse the repository at this point in the history
…okens (#1296)

Closes #1157
A source of confusion that we get occasionally get questions about is when users try to create org resources with an API key
API keys are org-scoped already and when they are used, the org header will be ignored by the API
This means that the resources will still be created but not where the user expects

This PR adds a validation on the Create function of every resource. If org_id is set and we're using an API key, then throw an error instead of the unexpected behavior
  • Loading branch information
julienduchesne authored Jan 24, 2024
1 parent 44f5cda commit c2152fb
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 63 deletions.
4 changes: 4 additions & 0 deletions internal/provider/configure_clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ func createGrafanaOAPIClient(client *common.Client, providerConfig frameworkProv
return err
}

if orgID > 1 && apiKey != "" {
return fmt.Errorf("org_id is only supported with basic auth. API keys are already org-scoped")
}

cfg := goapi.TransportConfig{
Host: client.GrafanaAPIURLParsed.Host,
BasePath: apiPath,
Expand Down
118 changes: 62 additions & 56 deletions internal/provider/legacy_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,44 +36,47 @@ func init() {
func Provider(version string) *schema.Provider {
var (
// Resources that require the Grafana client to exist.
grafanaClientResources = addResourcesMetadataValidation(grafanaClientPresent, map[string]*schema.Resource{
// Grafana
"grafana_annotation": grafana.ResourceAnnotation(),
"grafana_api_key": grafana.ResourceAPIKey(),
"grafana_contact_point": grafana.ResourceContactPoint(),
"grafana_dashboard": grafana.ResourceDashboard(),
"grafana_dashboard_public": grafana.ResourcePublicDashboard(),
"grafana_dashboard_permission": grafana.ResourceDashboardPermission(),
"grafana_data_source": grafana.ResourceDataSource(),
"grafana_data_source_permission": grafana.ResourceDatasourcePermission(),
"grafana_folder": grafana.ResourceFolder(),
"grafana_folder_permission": grafana.ResourceFolderPermission(),
"grafana_library_panel": grafana.ResourceLibraryPanel(),
"grafana_message_template": grafana.ResourceMessageTemplate(),
"grafana_mute_timing": grafana.ResourceMuteTiming(),
"grafana_notification_policy": grafana.ResourceNotificationPolicy(),
"grafana_organization": grafana.ResourceOrganization(),
"grafana_organization_preferences": grafana.ResourceOrganizationPreferences(),
"grafana_playlist": grafana.ResourcePlaylist(),
"grafana_report": grafana.ResourceReport(),
"grafana_role": grafana.ResourceRole(),
"grafana_role_assignment": grafana.ResourceRoleAssignment(),
"grafana_rule_group": grafana.ResourceRuleGroup(),
"grafana_team": grafana.ResourceTeam(),
"grafana_team_external_group": grafana.ResourceTeamExternalGroup(),
"grafana_service_account_token": grafana.ResourceServiceAccountToken(),
"grafana_service_account": grafana.ResourceServiceAccount(),
"grafana_service_account_permission": grafana.ResourceServiceAccountPermission(),
"grafana_user": grafana.ResourceUser(),

// Machine Learning
"grafana_machine_learning_job": machinelearning.ResourceJob(),
"grafana_machine_learning_holiday": machinelearning.ResourceHoliday(),
"grafana_machine_learning_outlier_detector": machinelearning.ResourceOutlierDetector(),

// SLO
"grafana_slo": slo.ResourceSlo(),
})
grafanaClientResources = addCreateReadResourcesMetadataValidation(
readGrafanaClientValidation,
createGrafanaClientValidation,
map[string]*schema.Resource{
// Grafana
"grafana_annotation": grafana.ResourceAnnotation(),
"grafana_api_key": grafana.ResourceAPIKey(),
"grafana_contact_point": grafana.ResourceContactPoint(),
"grafana_dashboard": grafana.ResourceDashboard(),
"grafana_dashboard_public": grafana.ResourcePublicDashboard(),
"grafana_dashboard_permission": grafana.ResourceDashboardPermission(),
"grafana_data_source": grafana.ResourceDataSource(),
"grafana_data_source_permission": grafana.ResourceDatasourcePermission(),
"grafana_folder": grafana.ResourceFolder(),
"grafana_folder_permission": grafana.ResourceFolderPermission(),
"grafana_library_panel": grafana.ResourceLibraryPanel(),
"grafana_message_template": grafana.ResourceMessageTemplate(),
"grafana_mute_timing": grafana.ResourceMuteTiming(),
"grafana_notification_policy": grafana.ResourceNotificationPolicy(),
"grafana_organization": grafana.ResourceOrganization(),
"grafana_organization_preferences": grafana.ResourceOrganizationPreferences(),
"grafana_playlist": grafana.ResourcePlaylist(),
"grafana_report": grafana.ResourceReport(),
"grafana_role": grafana.ResourceRole(),
"grafana_role_assignment": grafana.ResourceRoleAssignment(),
"grafana_rule_group": grafana.ResourceRuleGroup(),
"grafana_team": grafana.ResourceTeam(),
"grafana_team_external_group": grafana.ResourceTeamExternalGroup(),
"grafana_service_account_token": grafana.ResourceServiceAccountToken(),
"grafana_service_account": grafana.ResourceServiceAccount(),
"grafana_service_account_permission": grafana.ResourceServiceAccountPermission(),
"grafana_user": grafana.ResourceUser(),

// Machine Learning
"grafana_machine_learning_job": machinelearning.ResourceJob(),
"grafana_machine_learning_holiday": machinelearning.ResourceHoliday(),
"grafana_machine_learning_outlier_detector": machinelearning.ResourceOutlierDetector(),

// SLO
"grafana_slo": slo.ResourceSlo(),
})

// Resources that require the Synthetic Monitoring client to exist.
smClientResources = addResourcesMetadataValidation(smClientPresent, map[string]*schema.Resource{
Expand Down Expand Up @@ -106,24 +109,27 @@ func Provider(version string) *schema.Provider {
})

// Datasources that require the Grafana client to exist.
grafanaClientDatasources = addResourcesMetadataValidation(grafanaClientPresent, map[string]*schema.Resource{
"grafana_dashboard": grafana.DatasourceDashboard(),
"grafana_dashboards": grafana.DatasourceDashboards(),
"grafana_data_source": grafana.DatasourceDatasource(),
"grafana_folder": grafana.DatasourceFolder(),
"grafana_folders": grafana.DatasourceFolders(),
"grafana_library_panel": grafana.DatasourceLibraryPanel(),
"grafana_user": grafana.DatasourceUser(),
"grafana_users": grafana.DatasourceUsers(),
"grafana_role": grafana.DatasourceRole(),
"grafana_service_account": grafana.DatasourceServiceAccount(),
"grafana_team": grafana.DatasourceTeam(),
"grafana_organization": grafana.DatasourceOrganization(),
"grafana_organization_preferences": grafana.DatasourceOrganizationPreferences(),

// SLO
"grafana_slos": slo.DatasourceSlo(),
})
grafanaClientDatasources = addCreateReadResourcesMetadataValidation(
readGrafanaClientValidation,
createGrafanaClientValidation,
map[string]*schema.Resource{
"grafana_dashboard": grafana.DatasourceDashboard(),
"grafana_dashboards": grafana.DatasourceDashboards(),
"grafana_data_source": grafana.DatasourceDatasource(),
"grafana_folder": grafana.DatasourceFolder(),
"grafana_folders": grafana.DatasourceFolders(),
"grafana_library_panel": grafana.DatasourceLibraryPanel(),
"grafana_user": grafana.DatasourceUser(),
"grafana_users": grafana.DatasourceUsers(),
"grafana_role": grafana.DatasourceRole(),
"grafana_service_account": grafana.DatasourceServiceAccount(),
"grafana_team": grafana.DatasourceTeam(),
"grafana_organization": grafana.DatasourceOrganization(),
"grafana_organization_preferences": grafana.DatasourceOrganizationPreferences(),

// SLO
"grafana_slos": slo.DatasourceSlo(),
})

// Datasources that require the Synthetic Monitoring client to exist.
smClientDatasources = addResourcesMetadataValidation(smClientPresent, map[string]*schema.Resource{
Expand Down
30 changes: 23 additions & 7 deletions internal/provider/legacy_provider_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,37 +14,53 @@ import (
// These validations are added to the Create and Read functions of all resources,
// because they are entrypoints (code that will be run in all cases).

type metadataValidation func(resourceName string, m interface{}) error
type metadataValidation func(resourceName string, d *schema.ResourceData, m interface{}) error

func grafanaClientPresent(resourceName string, m interface{}) error {
func readGrafanaClientValidation(resourceName string, d *schema.ResourceData, m interface{}) error {
if m.(*common.Client).GrafanaOAPI == nil {
return fmt.Errorf("the Grafana client is required for `%s`. Set the auth and url provider attributes", resourceName)
}
return nil
}

func smClientPresent(resourceName string, m interface{}) error {
func createGrafanaClientValidation(resourceName string, d *schema.ResourceData, m interface{}) error {
if err := readGrafanaClientValidation(resourceName, d, m); err != nil {
return err
}
orgID, ok := d.GetOk("org_id")
orgIDStr, orgIDOk := orgID.(string)
if ok && orgIDOk && orgIDStr != "" && orgIDStr != "0" && m.(*common.Client).GrafanaAPIConfig.APIKey != "" {
return fmt.Errorf("org_id is only supported with basic auth. API keys are already org-scoped")
}
return nil
}

func smClientPresent(resourceName string, d *schema.ResourceData, m interface{}) error {
if m.(*common.Client).SMAPI == nil {
return fmt.Errorf("the Synthetic Monitoring client is required for `%s`. Set the sm_access_token provider attribute", resourceName)
}
return nil
}

func cloudClientPresent(resourceName string, m interface{}) error {
func cloudClientPresent(resourceName string, d *schema.ResourceData, m interface{}) error {
if m.(*common.Client).GrafanaCloudAPI == nil {
return fmt.Errorf("the Cloud API client is required for `%s`. Set the cloud_api_key provider attribute", resourceName)
}
return nil
}

func onCallClientPresent(resourceName string, m interface{}) error {
func onCallClientPresent(resourceName string, d *schema.ResourceData, m interface{}) error {
if m.(*common.Client).OnCallClient == nil {
return fmt.Errorf("the Oncall client is required for `%s`. Set the oncall_access_token provider attribute", resourceName)
}
return nil
}

func addResourcesMetadataValidation(validateFunc metadataValidation, resources map[string]*schema.Resource) map[string]*schema.Resource {
return addCreateReadResourcesMetadataValidation(validateFunc, validateFunc, resources)
}

func addCreateReadResourcesMetadataValidation(readValidateFunc, createValidateFunc metadataValidation, resources map[string]*schema.Resource) map[string]*schema.Resource {
for name, r := range resources {
name := name
//nolint:staticcheck
Expand All @@ -54,7 +70,7 @@ func addResourcesMetadataValidation(validateFunc metadataValidation, resources m
if r.ReadContext != nil {
prev := r.ReadContext
r.ReadContext = func(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
if err := validateFunc(name, m); err != nil {
if err := readValidateFunc(name, d, m); err != nil {
return diag.FromErr(err)
}
return prev(ctx, d, m)
Expand All @@ -67,7 +83,7 @@ func addResourcesMetadataValidation(validateFunc metadataValidation, resources m
if r.CreateContext != nil {
prev := r.CreateContext
r.CreateContext = func(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
if err := validateFunc(name, m); err != nil {
if err := createValidateFunc(name, d, m); err != nil {
return diag.FromErr(err)
}
return prev(ctx, d, m)
Expand Down
67 changes: 67 additions & 0 deletions internal/resources/grafana/resource_team_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package grafana_test

import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"testing"

"github.com/grafana/grafana-openapi-client-go/client/service_accounts"
"github.com/grafana/grafana-openapi-client-go/models"
"github.com/grafana/terraform-provider-grafana/internal/common"
"github.com/grafana/terraform-provider-grafana/internal/resources/grafana"
Expand Down Expand Up @@ -317,6 +321,69 @@ func TestAccResourceTeam_InOrg(t *testing.T) {
})
}

// This tests that API keys/service account tokens cannot be used at the same time as org_id
// because API keys are already org-scoped.
func TestAccTeam_OrgScopedOnAPIKey(t *testing.T) {
testutils.CheckOSSTestsEnabled(t, ">=9.1.0")

// Create a service account within an org
name := acctest.RandString(10)
globalClient := grafana.OAPIGlobalClient(testutils.Provider.Meta())
org, err := globalClient.Orgs.CreateOrg(&models.CreateOrgCommand{Name: name})
if err != nil {
t.Fatal(err)
}
defer func() {
if _, err := globalClient.Orgs.DeleteOrgByID(*org.Payload.OrgID); err != nil {
t.Fatal(err)
}
}()
orgClient := grafana.OAPIGlobalClient(testutils.Provider.Meta()).WithOrgID(*org.Payload.OrgID)
sa, err := orgClient.ServiceAccounts.CreateServiceAccount(
service_accounts.NewCreateServiceAccountParams().WithBody(&models.CreateServiceAccountForm{
Name: name,
Role: "Admin",
},
))
if err != nil {
t.Fatal(err)
}
saToken, err := orgClient.ServiceAccounts.CreateToken(
service_accounts.NewCreateTokenParams().WithBody(&models.AddServiceAccountTokenCommand{
Name: name,
},
).WithServiceAccountID(sa.Payload.ID),
)
if err != nil {
t.Fatal(err)
}

prevAuth := os.Getenv("GRAFANA_AUTH")
os.Setenv("GRAFANA_AUTH", saToken.Payload.Key)
defer os.Setenv("GRAFANA_AUTH", prevAuth)
resource.Test(t, resource.TestCase{
ProviderFactories: testutils.ProviderFactories,
Steps: []resource.TestStep{
{
Config: fmt.Sprintf(`resource "grafana_team" "test" {
org_id = %d
name = "test"
}`, *org.Payload.OrgID),
ExpectError: regexp.MustCompile("org_id is only supported with basic auth. API keys are already org-scoped"),
},
{
Config: `resource "grafana_team" "test" {
name = "test"
}`,
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("grafana_team.test", "name", "test"),
resource.TestCheckResourceAttr("grafana_team.test", "org_id", strconv.FormatInt(*org.Payload.OrgID, 10)),
),
},
},
})
}

func testAccTeamDefinition(name string, teamMembers []string, withPreferences bool, externalGroups []string) string {
withPreferencesBlock := ""
if withPreferences {
Expand Down

0 comments on commit c2152fb

Please sign in to comment.