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
+```