Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[datadog_organization_settings] Add Security Contacts support #2396

Merged
merged 3 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ datadog/**/*datadog_service_account* @DataDog/api-clients @DataDog/te
datadog/**/*datadog_spans_metric* @DataDog/api-clients @DataDog/apm-trace-intake
datadog/**/*datadog_synthetics_concurrency_cap* @DataDog/api-clients @DataDog/synthetics-app @DataDog/synthetics-ct
datadog/**/*datadog_team* @DataDog/api-clients @DataDog/core-app
datadog/**/*datadog_organization_settings* @DataDog/api-clients @DataDog/core-app @DataDog/trust-and-safety
10 changes: 10 additions & 0 deletions datadog/internal/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,13 @@ func NormMetricNameParse(name string) string {
return string(res)

}

// AnyToSlice casts a raw interface{} to a well-typed slice (useful for reading Terraform ResourceData)
func AnyToSlice[T any](raw any) []T {
rawSlice := raw.([]interface{})
result := make([]T, len(rawSlice))
for i, x := range rawSlice {
result[i] = x.(T)
}
return result
}
23 changes: 23 additions & 0 deletions datadog/internal/validators/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,29 @@ func ValidateAWSAccountID(v any, p cty.Path) diag.Diagnostics {
return diags
}

var _ schema.SchemaValidateDiagFunc = ValidateBasicEmail
var basicEmailRe = regexp.MustCompile("^[^@]+@[^@]+\\.[^@.]+$")
alexandre-pocquet marked this conversation as resolved.
Show resolved Hide resolved

// ValidateBasicEmail ensures a string looks like an email
func ValidateBasicEmail(val any, path cty.Path) diag.Diagnostics {
str, ok := val.(string)
if !ok {
return diag.Diagnostics{{
Severity: diag.Error,
Summary: fmt.Sprintf("not a string: %s", val),
AttributePath: path,
}}
}
if !basicEmailRe.MatchString(str) {
return diag.Diagnostics{{
Severity: diag.Error,
Summary: fmt.Sprintf("not a email: %s", str),
AttributePath: path,
}}
}
return nil
}

type BetweenValidator struct {
min float64
max float64
Expand Down
14 changes: 6 additions & 8 deletions datadog/resource_datadog_child_organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ func buildDatadogOrganizationCreateV1Struct(d *schema.ResourceData) *datadogV1.O
}

func updateOrganizationState(d *schema.ResourceData, org *datadogV1.Organization) diag.Diagnostics {
d.SetId(org.GetPublicId())
d.Set("name", org.GetName())
d.Set("public_id", org.GetPublicId())
d.Set("description", org.GetDescription())
Expand Down Expand Up @@ -333,14 +334,11 @@ func resourceDatadogChildOrganizationCreate(ctx context.Context, d *schema.Resou
applicationKey := resp.GetApplicationKey()
user := resp.GetUser()

publicId := org.GetPublicId()
d.SetId(publicId)

updateOrganizationApiKeyState(d, &apiKey)
updateOrganizationApplicationKeyState(d, &applicationKey)
updateOrganizationUserState(d, &user)

return updateOrganizationState(d, &org)
diags := updateOrganizationState(d, &org)
diags = append(diags, updateOrganizationApiKeyState(d, &apiKey)...)
diags = append(diags, updateOrganizationApplicationKeyState(d, &applicationKey)...)
diags = append(diags, updateOrganizationUserState(d, &user)...)
return diags
}

func resourceDatadogChildOrganizationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
Expand Down
129 changes: 117 additions & 12 deletions datadog/resource_datadog_organization_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ package datadog

import (
"context"

"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils"
"fmt"
"time"

"github.com/DataDog/datadog-api-client-go/v2/api/datadogV1"
"github.com/DataDog/datadog-api-client-go/v2/api/datadogV2"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/go-cty/cty/gocty"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"

"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils"
"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/validators"
)

func resourceDatadogOrganizationSettings() *schema.Resource {
Expand Down Expand Up @@ -40,6 +48,17 @@ func resourceDatadogOrganizationSettings() *schema.Resource {
Type: schema.TypeString,
Computed: true,
},
"security_contacts": {
Type: schema.TypeList,
Optional: true,
Computed: true,
Description: "List of emails used for security event notifications from the organization.",
Elem: &schema.Schema{
Type: schema.TypeString,
Description: "An email address to be used for security event notifications.",
ValidateDiagFunc: validators.ValidateBasicEmail,
},
},
"settings": {
Description: "Organization settings",
Type: schema.TypeList,
Expand All @@ -51,7 +70,7 @@ func resourceDatadogOrganizationSettings() *schema.Resource {
"private_widget_share": {
Type: schema.TypeBool,
Optional: true,
Default: false,
Default: false, // FIXME: leave it "unspecified" by default like the child org schema ?
Description: "Whether or not the organization users can share widgets outside of Datadog.",
},
"saml": {
Expand All @@ -73,7 +92,7 @@ func resourceDatadogOrganizationSettings() *schema.Resource {
"saml_autocreate_access_role": {
Type: schema.TypeString,
Optional: true,
Default: "st",
Default: "st", // FIXME: leave it "unspecified" by default like the child org schema ?
Description: "The access role of the user. Options are `st` (standard user), `adm` (admin user), or `ro` (read-only user). Allowed enum values: `st`, `adm` , `ro`, `ERROR`",
ValidateFunc: validation.StringInSlice([]string{"st", "adm", "ro", "ERROR"}, false),
},
Expand Down Expand Up @@ -253,6 +272,7 @@ func buildDatadogOrganizationUpdateV1Struct(d *schema.ResourceData) *datadogV1.O
}

func resourceDatadogOrganizationSettingsCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
// note: we don't actually create a new organization, we just import the org associated with the current API/APP keys
providerConf := meta.(*ProviderConfiguration)
apiInstances := providerConf.DatadogApiInstances
auth := providerConf.Auth
Expand Down Expand Up @@ -286,11 +306,11 @@ func resourceDatadogOrganizationSettingsRead(ctx context.Context, d *schema.Reso
}
return utils.TranslateClientErrorDiag(err, httpResponse, "error getting organization")
}

org := resp.GetOrg()
d.SetId(org.GetPublicId())

return updateOrganizationState(d, &org)
diags := updateOrganizationState(d, &org)
diags = append(diags, readSecurityContacts(providerConf, d)...)
return diags
}

func resourceDatadogOrganizationSettingsUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
Expand All @@ -302,13 +322,11 @@ func resourceDatadogOrganizationSettingsUpdate(ctx context.Context, d *schema.Re
if err != nil {
return utils.TranslateClientErrorDiag(err, httpResponse, "error updating organization")
}

org := resp.GetOrg()

publicId := org.GetPublicId()
d.SetId(publicId)

return updateOrganizationState(d, &org)
diags := updateOrganizationState(d, &org)
diags = append(diags, updateSecurityContacts(providerConf, d)...)
return diags
}

func resourceDatadogOrganizationSettingsDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
Expand All @@ -320,3 +338,90 @@ func resourceDatadogOrganizationSettingsDelete(ctx context.Context, d *schema.Re
Detail: "Remove organization by contacting support (https://docs.datadoghq.com/help/).",
})
}

/// Security Contacts ///

func readSecurityContacts(pc *ProviderConfiguration, d *schema.ResourceData) diag.Diagnostics {
body, resp, err := pc.DatadogApiInstances.GetOrganizationsApiV2().GetOrgConfig(pc.Auth, "security_contacts")
if err != nil {
// this API should not return 404, a default config value is always provided
return utils.TranslateClientErrorDiag(err, resp, "error getting security_contacts")
}

return updateSecurityContactState(body, d)
}

func updateSecurityContacts(pc *ProviderConfiguration, d *schema.ResourceData) diag.Diagnostics {
// ResourceData.HasChange doesn't work well with (nullable) slices: it uses the zero value (empty) even for
// unset config or missing state, so we manually check the raw values.
stateValue, diags := readNullableCtyAttr[[]string](d.GetRawState(), "security_contacts")
if diags != nil {
return diags
}
configValue, diags := readNullableCtyAttr[[]string](d.GetRawConfig(), "security_contacts")
if diags != nil {
return diags
}

// Skip the update when the config is unset or no change is needed.
if configValue == nil || cmp.Equal(stateValue, configValue) {
if stateValue == nil { // usually happens for brand-new resources
return readSecurityContacts(pc, d)
}
return nil
}
newValue := *configValue

body, resp, err := pc.DatadogApiInstances.GetOrganizationsApiV2().UpdateOrgConfig(pc.Auth, "security_contacts", datadogV2.OrgConfigWriteRequest{
Data: datadogV2.OrgConfigWrite{
Type: "org_configs", // required by the API
Attributes: datadogV2.OrgConfigWriteAttributes{
Value: newValue,
},
},
})
if err != nil {
return utils.TranslateClientErrorDiag(err, resp, "error setting security_contacts")
}

// The org_configs read API appears to be eventually consistent, so we wait for reads to return the new value.
// This is obviously a race condition: with concurrent updates we might never witness our own.
ctx := context.Background()
err = retry.RetryContext(ctx, 10*time.Second, func() *retry.RetryError {
body, _, err := pc.DatadogApiInstances.GetOrganizationsApiV2().GetOrgConfig(pc.Auth, "security_contacts")
if err != nil {
return retry.NonRetryableError(fmt.Errorf("unable to check security_contacts: %w", err))
}
remoteValue := utils.AnyToSlice[string](body.Data.Attributes.Value)
if cmp.Equal(newValue, remoteValue) {
return nil
} else {
return retry.RetryableError(fmt.Errorf("security_contacts not updated yet"))
}
})
if err != nil {
return diag.FromErr(fmt.Errorf("while waiting for security_contacts update: %w", err))
}

return updateSecurityContactState(body, d)
}

func readNullableCtyAttr[T any](root cty.Value, attr string) (*T, diag.Diagnostics) {
if root.IsNull() {
return nil, nil
}
ctyValue := root.GetAttr(attr)
if ctyValue.IsNull() {
return nil, nil
}
var value T
if err := gocty.FromCtyValue(ctyValue, &value); err != nil {
return nil, diag.FromErr(fmt.Errorf("error reading cty attr '%s': %w", attr, err))
}
return &value, nil
}

func updateSecurityContactState(remote datadogV2.OrgConfigGetResponse, d *schema.ResourceData) diag.Diagnostics {
value := utils.AnyToSlice[string](remote.Data.Attributes.Value)
return diag.FromErr(d.Set("security_contacts", value))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2024-06-19T11:38:22.059825+02:00
Loading
Loading