diff --git a/cloudsmith/provider.go b/cloudsmith/provider.go index 372e1ed..c7487f5 100644 --- a/cloudsmith/provider.go +++ b/cloudsmith/provider.go @@ -56,6 +56,7 @@ func Provider() *schema.Provider { "cloudsmith_oidc": resourceOIDC(), "cloudsmith_manage_team": resourceManageTeam(), "cloudsmith_saml": resourceSAML(), + "cloudsmith_saml_auth": resourceSAMLAuth(), "cloudsmith_repository_retention_rule": resourceRepoRetentionRule(), }, } diff --git a/cloudsmith/resource_saml_auth.go b/cloudsmith/resource_saml_auth.go new file mode 100644 index 0000000..35901e7 --- /dev/null +++ b/cloudsmith/resource_saml_auth.go @@ -0,0 +1,254 @@ +// Package cloudsmith provides Terraform provider functionality for managing Cloudsmith resources. +package cloudsmith + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + + "github.com/cloudsmith-io/cloudsmith-api-go" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// samlAuthCreate handles the creation of a new SAML authentication configuration +func samlAuthCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + pc := m.(*providerConfig) + organization := d.Get("organization").(string) + + samlAuth, err := buildSAMLAuthPatch(d) + if err != nil { + return diag.FromErr(fmt.Errorf("error building SAML auth request: %w", err)) + } + + req := pc.APIClient.OrgsApi.OrgsSamlAuthenticationPartialUpdate(pc.Auth, organization).Data(*samlAuth) + result, resp, err := pc.APIClient.OrgsApi.OrgsSamlAuthenticationPartialUpdateExecute(req) + if err != nil { + return diag.FromErr(handleSAMLAuthError(err, resp, "creating SAML authentication")) + } + + d.SetId(generateSAMLAuthID(organization, result)) + return samlAuthRead(ctx, d, m) +} + +// samlAuthRead retrieves the current SAML authentication configuration +func samlAuthRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + pc := m.(*providerConfig) + organization := d.Get("organization").(string) + + samlAuth, resp, err := pc.APIClient.OrgsApi.OrgsSamlAuthenticationRead(pc.Auth, organization).Execute() + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + d.SetId("") + return nil + } + return diag.FromErr(handleSAMLAuthError(err, resp, "reading SAML authentication")) + } + + if err := setSAMLAuthFields(d, organization, samlAuth); err != nil { + return diag.FromErr(err) + } + + return nil +} + +// samlAuthUpdate modifies an existing SAML authentication configuration +func samlAuthUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + pc := m.(*providerConfig) + organization := d.Get("organization").(string) + + samlAuth, err := buildSAMLAuthPatch(d) + if err != nil { + return diag.FromErr(fmt.Errorf("error building SAML auth request: %w", err)) + } + + req := pc.APIClient.OrgsApi.OrgsSamlAuthenticationPartialUpdate(pc.Auth, organization).Data(*samlAuth) + _, resp, err := pc.APIClient.OrgsApi.OrgsSamlAuthenticationPartialUpdateExecute(req) + if err != nil { + return diag.FromErr(handleSAMLAuthError(err, resp, "updating SAML authentication")) + } + + return samlAuthRead(ctx, d, m) +} + +// samlAuthDelete disables SAML authentication for the organization +func samlAuthDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + pc := m.(*providerConfig) + organization := d.Get("organization").(string) + + samlAuth := cloudsmith.NewOrganizationSAMLAuthRequestPatch() + samlAuth.SetSamlAuthEnabled(false) + samlAuth.SetSamlAuthEnforced(false) + samlAuth.SetSamlMetadataInline("") + samlAuth.SetSamlMetadataUrl("") + + req := pc.APIClient.OrgsApi.OrgsSamlAuthenticationPartialUpdate(pc.Auth, organization).Data(*samlAuth) + _, resp, err := pc.APIClient.OrgsApi.OrgsSamlAuthenticationPartialUpdateExecute(req) + if err != nil { + return diag.FromErr(handleSAMLAuthError(err, resp, "deleting SAML authentication")) + } + + d.SetId("") + return nil +} + +// samlAuthImport handles importing existing SAML authentication configurations +func samlAuthImport(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + pc := m.(*providerConfig) + organization := d.Id() + + samlAuth, resp, err := pc.APIClient.OrgsApi.OrgsSamlAuthenticationRead(pc.Auth, organization).Execute() + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("SAML authentication not found for organization %s", organization) + } + return nil, handleSAMLAuthError(err, resp, "importing SAML authentication") + } + + d.Set("organization", organization) + d.SetId(generateSAMLAuthID(organization, samlAuth)) + + if err := setSAMLAuthFields(d, organization, samlAuth); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} + +// buildSAMLAuthPatch creates a new SAML authentication patch request from the resource data +func buildSAMLAuthPatch(d *schema.ResourceData) (*cloudsmith.OrganizationSAMLAuthRequestPatch, error) { + samlAuth := cloudsmith.NewOrganizationSAMLAuthRequestPatch() + + samlAuth.SetSamlAuthEnabled(d.Get("saml_auth_enabled").(bool)) + samlAuth.SetSamlAuthEnforced(d.Get("saml_auth_enforced").(bool)) + + if v, ok := d.GetOk("saml_metadata_inline"); ok { + samlAuth.SetSamlMetadataInline(v.(string)) + samlAuth.SetSamlMetadataUrl("") + } + + if v, ok := d.GetOk("saml_metadata_url"); ok { + samlAuth.SetSamlMetadataUrl(v.(string)) + samlAuth.SetSamlMetadataInline("") + } + + return samlAuth, nil +} + +// setSAMLAuthFields updates the resource data with values from the API response +func setSAMLAuthFields(d *schema.ResourceData, organization string, samlAuth *cloudsmith.OrganizationSAMLAuth) error { + // Helper function to reduce repetition and standardize error handling + setField := func(key string, value interface{}) error { + if err := d.Set(key, value); err != nil { + return fmt.Errorf("error setting %s: %w", key, err) + } + return nil + } + + // Set the basic fields first - these are always set regardless of their value + if err := setField("organization", organization); err != nil { + return err + } + if err := setField("saml_auth_enabled", samlAuth.GetSamlAuthEnabled()); err != nil { + return err + } + if err := setField("saml_auth_enforced", samlAuth.GetSamlAuthEnforced()); err != nil { + return err + } + + // Handle inline metadata - only set if non-empty + if inlineMetadata := samlAuth.GetSamlMetadataInline(); inlineMetadata != "" { + if err := setField("saml_metadata_inline", inlineMetadata); err != nil { + return err + } + } + + // Handle URL metadata with null handling + url, hasURL := samlAuth.GetSamlMetadataUrlOk() + if !hasURL || *url == "" { + return setField("saml_metadata_url", nil) + } + return setField("saml_metadata_url", url) +} + +// generateSAMLAuthID creates a unique identifier for the SAML authentication resource +func generateSAMLAuthID(organization string, samlAuth *cloudsmith.OrganizationSAMLAuth) string { + data := organization + + if samlAuth != nil { + data += fmt.Sprintf("-%t", samlAuth.GetSamlAuthEnabled()) + data += fmt.Sprintf("-%t", samlAuth.GetSamlAuthEnforced()) + + if url, hasURL := samlAuth.GetSamlMetadataUrlOk(); hasURL { + data += fmt.Sprintf("-%s", *url) + } + + // Include inline metadata if present + if metadata := samlAuth.GetSamlMetadataInline(); metadata != "" { + data += fmt.Sprintf("-%s", metadata) + } + } + + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} + +// handleSAMLAuthError creates formatted error messages for API operations +func handleSAMLAuthError(err error, resp *http.Response, action string) error { + if resp != nil { + return fmt.Errorf("error %s: %v (status: %d)", action, err, resp.StatusCode) + } + return fmt.Errorf("error %s: %v", action, err) +} + +// resourceSAMLAuth returns a schema.Resource for managing SAML authentication configuration. +// This resource allows configuring SAML authentication settings for a Cloudsmith organization. +func resourceSAMLAuth() *schema.Resource { + return &schema.Resource{ + CreateContext: samlAuthCreate, + ReadContext: samlAuthRead, + UpdateContext: samlAuthUpdate, + DeleteContext: samlAuthDelete, + + Importer: &schema.ResourceImporter{ + StateContext: samlAuthImport, + }, + + Schema: map[string]*schema.Schema{ + "organization": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Organization slug for SAML authentication", + }, + "saml_auth_enabled": { + Type: schema.TypeBool, + Required: true, + Description: "Enable SAML authentication for the organization", + }, + "saml_auth_enforced": { + Type: schema.TypeBool, + Required: true, + Description: "Enforce SAML authentication for the organization", + }, + "saml_metadata_inline": { + Type: schema.TypeString, + Optional: true, + Description: "Inline SAML metadata XML", + ExactlyOneOf: []string{"saml_metadata_inline", "saml_metadata_url"}, + StateFunc: func(v interface{}) string { + return strings.TrimSpace(v.(string)) + }, + }, + "saml_metadata_url": { + Type: schema.TypeString, + Optional: true, + Description: "URL to fetch SAML metadata", + ExactlyOneOf: []string{"saml_metadata_inline", "saml_metadata_url"}, + }, + }, + } +} diff --git a/cloudsmith/resource_saml_auth_test.go b/cloudsmith/resource_saml_auth_test.go new file mode 100644 index 0000000..cc70938 --- /dev/null +++ b/cloudsmith/resource_saml_auth_test.go @@ -0,0 +1,154 @@ +package cloudsmith + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccSAMLAuth_basic(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccSAMLAuthCheckDestroy("cloudsmith_saml_auth.test"), + Steps: []resource.TestStep{ + { + // Basic configuration with URL-based metadata + Config: testAccSAMLAuthConfigBasic, + Check: resource.ComposeTestCheckFunc( + testAccSAMLAuthCheckExists("cloudsmith_saml_auth.test"), + resource.TestCheckResourceAttr("cloudsmith_saml_auth.test", "saml_auth_enabled", "true"), + resource.TestCheckResourceAttr("cloudsmith_saml_auth.test", "saml_auth_enforced", "false"), + resource.TestCheckResourceAttr("cloudsmith_saml_auth.test", "saml_metadata_url", "https://test.idp.example.com/metadata.xml"), + resource.TestCheckNoResourceAttr("cloudsmith_saml_auth.test", "saml_metadata_inline"), + ), + }, + { + // Update to use inline metadata + Config: testAccSAMLAuthConfigInlineMetadata, + Check: resource.ComposeTestCheckFunc( + testAccSAMLAuthCheckExists("cloudsmith_saml_auth.test"), + resource.TestCheckResourceAttr("cloudsmith_saml_auth.test", "saml_metadata_inline", testSAMLMetadata), + resource.TestCheckResourceAttr("cloudsmith_saml_auth.test", "saml_metadata_url", ""), + ), + }, + { + // Enable enforcement + Config: testAccSAMLAuthConfigEnforced, + Check: resource.ComposeTestCheckFunc( + testAccSAMLAuthCheckExists("cloudsmith_saml_auth.test"), + resource.TestCheckResourceAttr("cloudsmith_saml_auth.test", "saml_auth_enforced", "true"), + ), + }, + { + ResourceName: "cloudsmith_saml_auth.test", + ImportState: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return os.Getenv("CLOUDSMITH_NAMESPACE"), nil + }, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccSAMLAuthCheckDestroy(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + resourceState, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource not found: %s", resourceName) + } + + if resourceState.Primary.ID == "" { + return fmt.Errorf("resource id not set") + } + + pc := testAccProvider.Meta().(*providerConfig) + organization := resourceState.Primary.Attributes["organization"] + + req := pc.APIClient.OrgsApi.OrgsSamlAuthenticationRead(pc.Auth, organization) + samlAuth, resp, err := pc.APIClient.OrgsApi.OrgsSamlAuthenticationReadExecute(req) + if err != nil && !is404(resp) { + return fmt.Errorf("unable to verify SAML auth deletion: %w", err) + } + defer resp.Body.Close() + + // Resource is considered destroyed if SAML auth is disabled + if samlAuth != nil && samlAuth.GetSamlAuthEnabled() { + return fmt.Errorf("SAML authentication still enabled for organization: %s", organization) + } + + return nil + } +} + +func testAccSAMLAuthCheckExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + resourceState, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource not found: %s", resourceName) + } + + if resourceState.Primary.ID == "" { + return fmt.Errorf("resource id not set") + } + + pc := testAccProvider.Meta().(*providerConfig) + organization := resourceState.Primary.Attributes["organization"] + + req := pc.APIClient.OrgsApi.OrgsSamlAuthenticationRead(pc.Auth, organization) + _, resp, err := pc.APIClient.OrgsApi.OrgsSamlAuthenticationReadExecute(req) + if err != nil { + return fmt.Errorf("error checking SAML auth existence: %w", err) + } + defer resp.Body.Close() + + return nil + } +} + +// Sample SAML metadata for testing +var testSAMLMetadata = strings.TrimSpace(` + + + + +`) + +var testAccSAMLAuthConfigBasic = strings.TrimSpace(fmt.Sprintf(` +resource "cloudsmith_saml_auth" "test" { + organization = "%s" + saml_auth_enabled = true + saml_auth_enforced = false + saml_metadata_url = "https://test.idp.example.com/metadata.xml" +} +`, os.Getenv("CLOUDSMITH_NAMESPACE"))) + +var testAccSAMLAuthConfigInlineMetadata = strings.TrimSpace(fmt.Sprintf(` +resource "cloudsmith_saml_auth" "test" { + organization = "%s" + saml_auth_enabled = true + saml_auth_enforced = false + saml_metadata_inline = < + # + # + # + # + # + # EOF +} +``` + +## Argument Reference + +The following arguments are supported: + +* `organization` - (Required) Organization slug for SAML authentication. This value cannot be changed after creation. +* `saml_auth_enabled` - (Required) Enable or disable SAML authentication for the organization. +* `saml_auth_enforced` - (Required) Whether to enforce SAML authentication for the organization. +* `saml_metadata_url` - (Optional) URL to fetch SAML metadata from the identity provider. Exactly one of `saml_metadata_url` or `saml_metadata_inline` must be specified. +* `saml_metadata_inline` - (Optional) Inline SAML metadata XML from the identity provider. Exactly one of `saml_metadata_url` or `saml_metadata_inline` must be specified. + +## Import + +SAML authentication configuration can be imported using the organization slug: + +```shell +terraform import cloudsmith_saml_auth.example my-organization +```