diff --git a/docs/resources/external_auth_provider.md b/docs/resources/external_auth_provider.md new file mode 100644 index 00000000..fdcbf97c --- /dev/null +++ b/docs/resources/external_auth_provider.md @@ -0,0 +1,108 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "rhcs_external_auth_provider Resource - terraform-provider-rhcs" +subcategory: "" +description: |- + External authentication provider for ROSA HCP clusters. +--- + +# rhcs_external_auth_provider (Resource) + +External authentication provider for ROSA HCP clusters. + + + + +## Schema + +### Required + +- `cluster` (String) Identifier of the cluster. +- `id` (String) Unique identifier of the external authentication provider. +- `issuer` (Attributes) Token issuer configuration. (see [below for nested schema](#nestedatt--issuer)) + +### Optional + +- `claim` (Attributes) Claim configuration for token validation and mapping. (see [below for nested schema](#nestedatt--claim)) +- `clients` (Attributes List) Client configurations for the external authentication provider. (see [below for nested schema](#nestedatt--clients)) + + +### Nested Schema for `issuer` + +Required: + +- `audiences` (Set of String) List of audiences for the token issuer. +- `url` (String) URL of the token issuer. + +Optional: + +- `ca` (String) Certificate Authority (CA) certificate content. + + + +### Nested Schema for `claim` + +Optional: + +- `mappings` (Attributes) Token claim mappings. (see [below for nested schema](#nestedatt--claim--mappings)) +- `validation_rules` (Attributes List) Token claim validation rules. (see [below for nested schema](#nestedatt--claim--validation_rules)) + + +### Nested Schema for `claim.mappings` + +Optional: + +- `groups` (Attributes) Groups claim mapping. (see [below for nested schema](#nestedatt--claim--mappings--groups)) +- `username` (Attributes) Username claim mapping. (see [below for nested schema](#nestedatt--claim--mappings--username)) + + +### Nested Schema for `claim.mappings.groups` + +Optional: + +- `claim` (String) Token claim to extract groups from. +- `prefix` (String) Prefix to apply to group names. + + + +### Nested Schema for `claim.mappings.username` + +Optional: + +- `claim` (String) Token claim to extract username from. +- `prefix` (String) Prefix to apply to username. +- `prefix_policy` (String) Policy for applying the prefix. + + + + +### Nested Schema for `claim.validation_rules` + +Required: + +- `claim` (String) Token claim to validate. +- `required_value` (String) Required value for the claim. + + + + +### Nested Schema for `clients` + +Optional: + +- `component` (Attributes) Component configuration. (see [below for nested schema](#nestedatt--clients--component)) +- `extra_scopes` (Set of String) Additional OAuth scopes. +- `id` (String) Client identifier. +- `secret` (String, Sensitive) Client secret (required if client ID is provided). + +Read-Only: + +- `type` (String) Client type (confidential or public). + + +### Nested Schema for `clients.component` + +Optional: + +- `name` (String) Component name. +- `namespace` (String) Component namespace. diff --git a/provider/external_auth_provider/resource.go b/provider/external_auth_provider/resource.go new file mode 100644 index 00000000..086d36f5 --- /dev/null +++ b/provider/external_auth_provider/resource.go @@ -0,0 +1,795 @@ +package external_auth_provider + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + v1 "github.com/openshift-online/ocm-api-model/clientapi/clustersmgmt/v1" + sdk "github.com/openshift-online/ocm-sdk-go" + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + + "github.com/terraform-redhat/terraform-provider-rhcs/provider/common" +) + +var _ resource.ResourceWithConfigure = &Resource{} +var _ resource.ResourceWithImportState = &Resource{} +var _ resource.ResourceWithValidateConfig = &Resource{} +var _ resource.ResourceWithConfigValidators = &Resource{} + +type Resource struct { + collection *cmv1.ClustersClient + clusterWait common.ClusterWait +} + +func New() resource.Resource { + return &Resource{} +} + +func (r *Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_external_auth_provider" +} + +func (r *Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "External authentication provider for ROSA HCP clusters.", + Attributes: map[string]schema.Attribute{ + "cluster": schema.StringAttribute{ + Description: "Identifier of the cluster.", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches(regexp.MustCompile(`.*\S.*`), "cluster ID may not be empty/blank string"), + }, + }, + "id": schema.StringAttribute{ + Description: "Unique identifier of the external authentication provider.", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches(regexp.MustCompile(`.*\S.*`), "provider ID may not be empty/blank string"), + }, + }, + "issuer": schema.SingleNestedAttribute{ + Description: "Token issuer configuration.", + Required: true, + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{ + Description: "URL of the token issuer.", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches(regexp.MustCompile(`^https://.*`), "issuer URL must use HTTPS"), + }, + }, + "audiences": schema.SetAttribute{ + Description: "List of audiences for the token issuer.", + Required: true, + ElementType: types.StringType, + }, + "ca": schema.StringAttribute{ + Description: "Certificate Authority (CA) certificate content.", + Optional: true, + }, + }, + }, + "clients": schema.ListNestedAttribute{ + Description: "Client configurations for the external authentication provider.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "component": schema.SingleNestedAttribute{ + Description: "Component configuration.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Component name.", + Optional: true, + }, + "namespace": schema.StringAttribute{ + Description: "Component namespace.", + Optional: true, + }, + }, + }, + "id": schema.StringAttribute{ + Description: "Client identifier.", + Optional: true, + }, + "secret": schema.StringAttribute{ + Description: "Client secret (required if client ID is provided).", + Optional: true, + Sensitive: true, + }, + "extra_scopes": schema.SetAttribute{ + Description: "Additional OAuth scopes.", + Optional: true, + ElementType: types.StringType, + }, + "type": schema.StringAttribute{ + Description: "Client type (confidential or public).", + Computed: true, + }, + }, + }, + }, + "claim": schema.SingleNestedAttribute{ + Description: "Claim configuration for token validation and mapping.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "mappings": schema.SingleNestedAttribute{ + Description: "Token claim mappings.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "username": schema.SingleNestedAttribute{ + Description: "Username claim mapping.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "claim": schema.StringAttribute{ + Description: "Token claim to extract username from.", + Optional: true, + }, + "prefix": schema.StringAttribute{ + Description: "Prefix to apply to username.", + Optional: true, + }, + "prefix_policy": schema.StringAttribute{ + Description: "Policy for applying the prefix.", + Optional: true, + }, + }, + }, + "groups": schema.SingleNestedAttribute{ + Description: "Groups claim mapping.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "claim": schema.StringAttribute{ + Description: "Token claim to extract groups from.", + Optional: true, + }, + "prefix": schema.StringAttribute{ + Description: "Prefix to apply to group names.", + Optional: true, + }, + }, + }, + }, + }, + "validation_rules": schema.ListNestedAttribute{ + Description: "Token claim validation rules.", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "claim": schema.StringAttribute{ + Description: "Token claim to validate.", + Required: true, + }, + "required_value": schema.StringAttribute{ + Description: "Required value for the claim.", + Required: true, + }, + }, + }, + Validators: []validator.List{ + listvalidator.SizeAtLeast(0), + }, + }, + }, + }, + }, + } +} + +func (r *Resource) ConfigValidators(context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + // Validation rules claim and required_value must be specified together + resourcevalidator.RequiredTogether( + path.MatchRoot("claim").AtName("validation_rules").AtAnyListIndex().AtName("claim"), + path.MatchRoot("claim").AtName("validation_rules").AtAnyListIndex().AtName("required_value"), + ), + } +} + +func (r *Resource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + connection, ok := req.ProviderData.(*sdk.Connection) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *sdk.Connection, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.collection = connection.ClustersMgmt().V1().Clusters() + r.clusterWait = common.NewClusterWait(r.collection, connection) +} + +func (r *Resource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var config State + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + // Validate client configuration - this requires iterating through dynamic lists + if !config.Clients.IsNull() && !config.Clients.IsUnknown() { + var clients []ExternalAuthClientConfig + resp.Diagnostics.Append(config.Clients.ElementsAs(ctx, &clients, false)...) + if resp.Diagnostics.HasError() { + return + } + + for i, client := range clients { + // If client ID is provided, secret must also be provided + if !client.ID.IsNull() && !client.ID.IsUnknown() && client.ID.ValueString() != "" { + if client.Secret.IsNull() || client.Secret.IsUnknown() || client.Secret.ValueString() == "" { + resp.Diagnostics.AddAttributeError( + path.Root("clients").AtListIndex(i).AtName("secret"), + "Missing Required Field", + "Client secret is required when client ID is provided.", + ) + } + } + } + } +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan State + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + clusterID := plan.Cluster.ValueString() + providerID := plan.ID.ValueString() + + // Wait till the cluster is ready + cluster, err := r.clusterWait.WaitForClusterToBeReady(ctx, clusterID, 60) + if err != nil { + resp.Diagnostics.AddError( + "Cannot poll cluster state", + fmt.Sprintf( + "Cannot poll state of cluster with identifier '%s': %v", + clusterID, err, + ), + ) + return + } + + // Verify that external auth configuration is enabled on the cluster + if cluster.ExternalAuthConfig() == nil || !cluster.ExternalAuthConfig().Enabled() { + resp.Diagnostics.AddError( + "External authentication not enabled", + fmt.Sprintf( + "External authentication configuration is not enabled for cluster '%s'. "+ + "Please enable external authentication on the cluster before creating external auth providers.", + clusterID, + ), + ) + return + } + + // Build the external auth object + externalAuth, err := r.buildExternalAuth(ctx, &plan, resp) + if err != nil || resp.Diagnostics.HasError() { + return + } + + // Create the external auth provider + addResp, err := r.collection.Cluster(clusterID).ExternalAuthConfig().ExternalAuths().Add(). + Body(externalAuth).SendContext(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create external auth provider", + fmt.Sprintf("Cannot create external authentication provider '%s' for cluster '%s': %v", providerID, clusterID, err), + ) + return + } + + // Update state with the created resource + err = r.populateState(ctx, addResp.Body(), &plan) + if err != nil { + resp.Diagnostics.AddError( + "Failed to populate state", + fmt.Sprintf("Cannot populate state after creating external auth provider: %v", err), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state State + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + clusterID := state.Cluster.ValueString() + providerID := state.ID.ValueString() + + // Get the external auth provider + getResp, err := r.collection.Cluster(clusterID).ExternalAuthConfig().ExternalAuths(). + ExternalAuth(providerID).Get().SendContext(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Failed to read external auth provider", + fmt.Sprintf("Cannot read external authentication provider '%s' for cluster '%s': %v", providerID, clusterID, err), + ) + return + } + + // Update state with the current resource + err = r.populateState(ctx, getResp.Body(), &state) + if err != nil { + resp.Diagnostics.AddError( + "Failed to populate state", + fmt.Sprintf("Cannot populate state after reading external auth provider: %v", err), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan State + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var state State + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + clusterID := plan.Cluster.ValueString() + providerID := plan.ID.ValueString() + + // Build the external auth object with updated configuration + externalAuth, err := r.buildExternalAuth(ctx, &plan, resp) + if err != nil || resp.Diagnostics.HasError() { + return + } + + // Update the external auth provider + updateResp, err := r.collection.Cluster(clusterID).ExternalAuthConfig().ExternalAuths(). + ExternalAuth(providerID).Update().Body(externalAuth).SendContext(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Failed to update external auth provider", + fmt.Sprintf("Cannot update external authentication provider '%s' for cluster '%s': %v", providerID, clusterID, err), + ) + return + } + + // Update state with the updated resource + err = r.populateState(ctx, updateResp.Body(), &plan) + if err != nil { + resp.Diagnostics.AddError( + "Failed to populate state", + fmt.Sprintf("Cannot populate state after updating external auth provider: %v", err), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state State + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + clusterID := state.Cluster.ValueString() + providerID := state.ID.ValueString() + + // Delete the external auth provider + _, err := r.collection.Cluster(clusterID).ExternalAuthConfig().ExternalAuths(). + ExternalAuth(providerID).Delete().SendContext(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Failed to delete external auth provider", + fmt.Sprintf("Cannot delete external authentication provider '%s' for cluster '%s': %v", providerID, clusterID, err), + ) + return + } + + // Remove the resource from state + resp.State.RemoveResource(ctx) +} + +func (r *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + tflog.Debug(ctx, "begin importstate()") + fields := strings.Split(req.ID, ",") + if len(fields) != 2 || fields[0] == "" || fields[1] == "" { + resp.Diagnostics.AddError( + "Invalid import identifier", + "External auth provider to import should be specified as ,", + ) + return + } + clusterID := fields[0] + providerID := fields[1] + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("cluster"), clusterID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), providerID)...) +} + +func (r *Resource) populateState(ctx context.Context, externalAuth *cmv1.ExternalAuth, state *State) error { + if state == nil { + state = &State{} + } + + // Set basic attributes + state.ID = types.StringValue(externalAuth.ID()) + + // Set issuer configuration + if externalAuth.Issuer() != nil { + issuer := &TokenIssuer{} + issuer.URL = types.StringValue(externalAuth.Issuer().URL()) + + if audiences := externalAuth.Issuer().Audiences(); len(audiences) > 0 { + audienceValues := make([]types.String, len(audiences)) + for i, audience := range audiences { + audienceValues[i] = types.StringValue(audience) + } + audienceSet, diag := types.SetValueFrom(ctx, types.StringType, audienceValues) + if diag.HasError() { + return fmt.Errorf("failed to populate audiences: %v", diag.Errors()) + } + issuer.Audiences = audienceSet + } + + if ca := externalAuth.Issuer().CA(); ca != "" { + issuer.CA = types.StringValue(ca) + } else { + issuer.CA = types.StringNull() + } + + state.Issuer = issuer + } + + // Set clients configuration + if clients := externalAuth.Clients(); len(clients) > 0 { + clientConfigs := make([]ExternalAuthClientConfig, len(clients)) + for i, client := range clients { + config := ExternalAuthClientConfig{} + + if client.Component() != nil { + config.Component = &ClientComponent{ + Name: types.StringValue(client.Component().Name()), + Namespace: types.StringValue(client.Component().Namespace()), + } + } + + if id := client.ID(); id != "" { + config.ID = types.StringValue(id) + } else { + config.ID = types.StringNull() + } + + if secret := client.Secret(); secret != "" { + config.Secret = types.StringValue(secret) + } else { + config.Secret = types.StringNull() + } + + if scopes := client.ExtraScopes(); len(scopes) > 0 { + scopeValues := make([]types.String, len(scopes)) + for j, scope := range scopes { + scopeValues[j] = types.StringValue(scope) + } + scopeSet, diag := types.SetValueFrom(ctx, types.StringType, scopeValues) + if diag.HasError() { + return fmt.Errorf("failed to populate extra scopes: %v", diag.Errors()) + } + config.ExtraScopes = scopeSet + } else { + config.ExtraScopes = types.SetNull(types.StringType) + } + + // Set computed type based on whether client has ID/secret + if !config.ID.IsNull() && !config.Secret.IsNull() { + config.Type = types.StringValue("confidential") + } else { + config.Type = types.StringValue("public") + } + + clientConfigs[i] = config + } + + clientList, diag := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "component": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "namespace": types.StringType, + }, + }, + "id": types.StringType, + "secret": types.StringType, + "extra_scopes": types.SetType{ElemType: types.StringType}, + "type": types.StringType, + }, + }, clientConfigs) + if diag.HasError() { + return fmt.Errorf("failed to populate clients: %v", diag.Errors()) + } + state.Clients = clientList + } else { + state.Clients = types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "component": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "namespace": types.StringType, + }, + }, + "id": types.StringType, + "secret": types.StringType, + "extra_scopes": types.SetType{ElemType: types.StringType}, + "type": types.StringType, + }, + }) + } + + // Set claim configuration + if externalAuth.Claim() != nil { + claim := &ExternalAuthClaim{} + + if externalAuth.Claim().Mappings() != nil { + mappings := &TokenClaimMappings{} + + if externalAuth.Claim().Mappings().UserName() != nil { + username := &UsernameClaim{} + if usernameClaim := externalAuth.Claim().Mappings().UserName().Claim(); usernameClaim != "" { + username.Claim = types.StringValue(usernameClaim) + } else { + username.Claim = types.StringNull() + } + if prefix := externalAuth.Claim().Mappings().UserName().Prefix(); prefix != "" { + username.Prefix = types.StringValue(prefix) + } else { + username.Prefix = types.StringNull() + } + if prefixPolicy := externalAuth.Claim().Mappings().UserName().PrefixPolicy(); prefixPolicy != "" { + username.PrefixPolicy = types.StringValue(prefixPolicy) + } else { + username.PrefixPolicy = types.StringNull() + } + mappings.Username = username + } + + if externalAuth.Claim().Mappings().Groups() != nil { + groups := &GroupsClaim{} + if groupsClaim := externalAuth.Claim().Mappings().Groups().Claim(); groupsClaim != "" { + groups.Claim = types.StringValue(groupsClaim) + } else { + groups.Claim = types.StringNull() + } + if prefix := externalAuth.Claim().Mappings().Groups().Prefix(); prefix != "" { + groups.Prefix = types.StringValue(prefix) + } else { + groups.Prefix = types.StringNull() + } + mappings.Groups = groups + } + + claim.Mappings = mappings + } + + if validationRules := externalAuth.Claim().ValidationRules(); len(validationRules) > 0 { + rules := make([]TokenClaimValidationRule, len(validationRules)) + for i, rule := range validationRules { + rules[i] = TokenClaimValidationRule{ + Claim: types.StringValue(rule.Claim()), + RequiredValue: types.StringValue(rule.RequiredValue()), + } + } + + rulesList, diag := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "claim": types.StringType, + "required_value": types.StringType, + }, + }, rules) + if diag.HasError() { + return fmt.Errorf("failed to populate validation rules: %v", diag.Errors()) + } + claim.ValidationRules = rulesList + } else { + claim.ValidationRules = types.ListNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "claim": types.StringType, + "required_value": types.StringType, + }, + }) + } + + state.Claim = claim + } + + return nil +} + +func (r *Resource) buildExternalAuth(ctx context.Context, plan *State, resp interface{}) (*cmv1.ExternalAuth, error) { + // Build the external auth object + externalAuthBuilder := cmv1.NewExternalAuth().ID(plan.ID.ValueString()) + + // Set issuer configuration + if plan.Issuer != nil { + issuerBuilder := cmv1.NewTokenIssuer() + + if !plan.Issuer.URL.IsNull() { + issuerBuilder.URL(plan.Issuer.URL.ValueString()) + } + + if !plan.Issuer.Audiences.IsNull() { + var audiences []string + if createResp, ok := resp.(*resource.CreateResponse); ok { + createResp.Diagnostics.Append(plan.Issuer.Audiences.ElementsAs(ctx, &audiences, false)...) + if createResp.Diagnostics.HasError() { + return nil, fmt.Errorf("failed to process audiences") + } + } else if updateResp, ok := resp.(*resource.UpdateResponse); ok { + updateResp.Diagnostics.Append(plan.Issuer.Audiences.ElementsAs(ctx, &audiences, false)...) + if updateResp.Diagnostics.HasError() { + return nil, fmt.Errorf("failed to process audiences") + } + } + issuerBuilder.Audiences(audiences...) + } + + if !plan.Issuer.CA.IsNull() { + issuerBuilder.CA(plan.Issuer.CA.ValueString()) + } + + externalAuthBuilder.Issuer(issuerBuilder) + } + + // Set clients configuration + if !plan.Clients.IsNull() { + var clients []ExternalAuthClientConfig + if createResp, ok := resp.(*resource.CreateResponse); ok { + createResp.Diagnostics.Append(plan.Clients.ElementsAs(ctx, &clients, false)...) + if createResp.Diagnostics.HasError() { + return nil, fmt.Errorf("failed to process clients") + } + } else if updateResp, ok := resp.(*resource.UpdateResponse); ok { + updateResp.Diagnostics.Append(plan.Clients.ElementsAs(ctx, &clients, false)...) + if updateResp.Diagnostics.HasError() { + return nil, fmt.Errorf("failed to process clients") + } + } + + clientBuilders := make([]*v1.ExternalAuthClientConfigBuilder, 0) + for _, client := range clients { + clientBuilder := cmv1.NewExternalAuthClientConfig() + + if client.Component != nil { + compBuilder := cmv1.NewClientComponent() + if !client.Component.Name.IsNull() { + compBuilder.Name(client.Component.Name.ValueString()) + } + if !client.Component.Namespace.IsNull() { + compBuilder.Namespace(client.Component.Namespace.ValueString()) + } + clientBuilder.Component(compBuilder) + } + + if !client.ID.IsNull() { + clientBuilder.ID(client.ID.ValueString()) + } + + if !client.Secret.IsNull() { + clientBuilder.Secret(client.Secret.ValueString()) + } + + if !client.ExtraScopes.IsNull() { + var scopes []string + if createResp, ok := resp.(*resource.CreateResponse); ok { + createResp.Diagnostics.Append(client.ExtraScopes.ElementsAs(ctx, &scopes, false)...) + if createResp.Diagnostics.HasError() { + return nil, fmt.Errorf("failed to process extra scopes") + } + } else if updateResp, ok := resp.(*resource.UpdateResponse); ok { + updateResp.Diagnostics.Append(client.ExtraScopes.ElementsAs(ctx, &scopes, false)...) + if updateResp.Diagnostics.HasError() { + return nil, fmt.Errorf("failed to process extra scopes") + } + } + clientBuilder.ExtraScopes(scopes...) + } + clientBuilders = append(clientBuilders, clientBuilder) + } + externalAuthBuilder.Clients(clientBuilders...) + } + + // Set claim configuration + if plan.Claim != nil { + claimBuilder := cmv1.NewExternalAuthClaim() + + if plan.Claim.Mappings != nil { + mappingsBuilder := cmv1.NewTokenClaimMappings() + + if plan.Claim.Mappings.Username != nil { + usernameBuilder := cmv1.NewUsernameClaim() + if !plan.Claim.Mappings.Username.Claim.IsNull() { + usernameBuilder.Claim(plan.Claim.Mappings.Username.Claim.ValueString()) + } + if !plan.Claim.Mappings.Username.Prefix.IsNull() { + usernameBuilder.Prefix(plan.Claim.Mappings.Username.Prefix.ValueString()) + } + if !plan.Claim.Mappings.Username.PrefixPolicy.IsNull() { + usernameBuilder.PrefixPolicy(plan.Claim.Mappings.Username.PrefixPolicy.ValueString()) + } + mappingsBuilder.UserName(usernameBuilder) + } + + if plan.Claim.Mappings.Groups != nil { + groupsBuilder := cmv1.NewGroupsClaim() + if !plan.Claim.Mappings.Groups.Claim.IsNull() { + groupsBuilder.Claim(plan.Claim.Mappings.Groups.Claim.ValueString()) + } + if !plan.Claim.Mappings.Groups.Prefix.IsNull() { + groupsBuilder.Prefix(plan.Claim.Mappings.Groups.Prefix.ValueString()) + } + mappingsBuilder.Groups(groupsBuilder) + } + claimBuilder.Mappings(mappingsBuilder) + } + + if !plan.Claim.ValidationRules.IsNull() { + var rules []TokenClaimValidationRule + if createResp, ok := resp.(*resource.CreateResponse); ok { + createResp.Diagnostics.Append(plan.Claim.ValidationRules.ElementsAs(ctx, &rules, false)...) + if createResp.Diagnostics.HasError() { + return nil, fmt.Errorf("failed to process validation rules") + } + } else if updateResp, ok := resp.(*resource.UpdateResponse); ok { + updateResp.Diagnostics.Append(plan.Claim.ValidationRules.ElementsAs(ctx, &rules, false)...) + if updateResp.Diagnostics.HasError() { + return nil, fmt.Errorf("failed to process validation rules") + } + } + + for _, rule := range rules { + ruleBuilder := cmv1.NewTokenClaimValidationRule() + if !rule.Claim.IsNull() { + ruleBuilder.Claim(rule.Claim.ValueString()) + } + if !rule.RequiredValue.IsNull() { + ruleBuilder.RequiredValue(rule.RequiredValue.ValueString()) + } + claimBuilder.ValidationRules(ruleBuilder) + } + } + externalAuthBuilder.Claim(claimBuilder) + } + + // Build the external auth object + externalAuth, err := externalAuthBuilder.Build() + if err != nil { + return nil, fmt.Errorf("cannot build external authentication: %v", err) + } + + return externalAuth, nil +} diff --git a/provider/external_auth_provider/state.go b/provider/external_auth_provider/state.go new file mode 100644 index 00000000..d2e0b4b5 --- /dev/null +++ b/provider/external_auth_provider/state.go @@ -0,0 +1,58 @@ +package external_auth_provider + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type State struct { + Cluster types.String `tfsdk:"cluster"` + ID types.String `tfsdk:"id"` + Issuer *TokenIssuer `tfsdk:"issuer"` + Clients types.List `tfsdk:"clients"` + Claim *ExternalAuthClaim `tfsdk:"claim"` +} + +type TokenIssuer struct { + URL types.String `tfsdk:"url"` + Audiences types.Set `tfsdk:"audiences"` + CA types.String `tfsdk:"ca"` +} + +type ExternalAuthClaim struct { + Mappings *TokenClaimMappings `tfsdk:"mappings"` + ValidationRules types.List `tfsdk:"validation_rules"` +} + +type TokenClaimMappings struct { + Username *UsernameClaim `tfsdk:"username"` + Groups *GroupsClaim `tfsdk:"groups"` +} + +type UsernameClaim struct { + Claim types.String `tfsdk:"claim"` + Prefix types.String `tfsdk:"prefix"` + PrefixPolicy types.String `tfsdk:"prefix_policy"` +} + +type GroupsClaim struct { + Claim types.String `tfsdk:"claim"` + Prefix types.String `tfsdk:"prefix"` +} + +type TokenClaimValidationRule struct { + Claim types.String `tfsdk:"claim"` + RequiredValue types.String `tfsdk:"required_value"` +} + +type ExternalAuthClientConfig struct { + Component *ClientComponent `tfsdk:"component"` + ID types.String `tfsdk:"id"` + Secret types.String `tfsdk:"secret"` + ExtraScopes types.Set `tfsdk:"extra_scopes"` + Type types.String `tfsdk:"type"` +} + +type ClientComponent struct { + Name types.String `tfsdk:"name"` + Namespace types.String `tfsdk:"namespace"` +} \ No newline at end of file diff --git a/provider/provider.go b/provider/provider.go index 93fcfb25..3181f951 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -41,6 +41,7 @@ import ( defaultingress "github.com/terraform-redhat/terraform-provider-rhcs/provider/defaultingress/classic" hcpingress "github.com/terraform-redhat/terraform-provider-rhcs/provider/defaultingress/hcp" "github.com/terraform-redhat/terraform-provider-rhcs/provider/dnsdomain" + "github.com/terraform-redhat/terraform-provider-rhcs/provider/external_auth_provider" "github.com/terraform-redhat/terraform-provider-rhcs/provider/group" "github.com/terraform-redhat/terraform-provider-rhcs/provider/groupmembership" "github.com/terraform-redhat/terraform-provider-rhcs/provider/identityprovider" @@ -220,6 +221,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ clusterwaiter.New, dnsdomain.New, + external_auth_provider.New, groupmembership.New, imagemirror.New, machinepool.New, diff --git a/subsystem/hcp/external_auth_provider_resource_test.go b/subsystem/hcp/external_auth_provider_resource_test.go new file mode 100644 index 00000000..ec3701ab --- /dev/null +++ b/subsystem/hcp/external_auth_provider_resource_test.go @@ -0,0 +1,950 @@ +package hcp + +import ( + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2/dsl/core" // nolint + . "github.com/onsi/gomega" // nolint + . "github.com/onsi/gomega/ghttp" // nolint + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + . "github.com/openshift-online/ocm-sdk-go/testing" // nolint + . "github.com/terraform-redhat/terraform-provider-rhcs/subsystem/framework" +) + +const ( + testClusterRoute = "/api/clusters_mgmt/v1/clusters/test-cluster" + testExternalAuthRoute = "/api/clusters_mgmt/v1/clusters/test-cluster/external_auth_config/external_auths" + testProviderRoute = "/api/clusters_mgmt/v1/clusters/test-cluster/external_auth_config/external_auths/test-provider" +) + +var _ = Describe("External Auth Provider", func() { + + // Create cluster template without external auth enabled + clusterWithoutExternalAuthTemplate := func() string { + cluster, err := cmv1.NewCluster(). + ID("test-cluster"). + Name("test-cluster"). + State(cmv1.ClusterStateReady). + Build() + Expect(err).ToNot(HaveOccurred()) + + b := new(strings.Builder) + err = cmv1.MarshalCluster(cluster, b) + Expect(err).ToNot(HaveOccurred()) + return b.String() + } + + // Create cluster template with external auth enabled + clusterWithExternalAuthTemplate := func() string { + cluster, err := cmv1.NewCluster(). + ID("test-cluster"). + Name("test-cluster"). + State(cmv1.ClusterStateReady). + ExternalAuthConfig(cmv1.NewExternalAuthConfig().Enabled(true)). + Build() + Expect(err).ToNot(HaveOccurred()) + + b := new(strings.Builder) + err = cmv1.MarshalCluster(cluster, b) + Expect(err).ToNot(HaveOccurred()) + return b.String() + } + + // Create external auth provider template + externalAuthProviderTemplate := func() string { + provider, err := cmv1.NewExternalAuth(). + ID("test-provider"). + Issuer(cmv1.NewTokenIssuer(). + URL("https://example.com"). + Audiences("audience1")). + Build() + Expect(err).ToNot(HaveOccurred()) + + b := new(strings.Builder) + err = cmv1.MarshalExternalAuth(provider, b) + Expect(err).ToNot(HaveOccurred()) + return b.String() + } + + // Create updated external auth provider template + updatedExternalAuthProviderTemplate := func() string { + provider, err := cmv1.NewExternalAuth(). + ID("test-provider"). + Issuer(cmv1.NewTokenIssuer(). + URL("https://updated-example.com"). + Audiences("audience1", "audience2")). + Build() + Expect(err).ToNot(HaveOccurred()) + + b := new(strings.Builder) + err = cmv1.MarshalExternalAuth(provider, b) + Expect(err).ToNot(HaveOccurred()) + return b.String() + } + + // Create external auth provider template with console clients + externalAuthProviderWithConsoleTemplate := func() string { + provider, err := cmv1.NewExternalAuth(). + ID("test-provider"). + Issuer(cmv1.NewTokenIssuer(). + URL("https://example.com"). + Audiences("audience1")). + Clients(cmv1.NewExternalAuthClientConfig(). + ID("console-client"). + Secret("console-secret")). + Build() + Expect(err).ToNot(HaveOccurred()) + + b := new(strings.Builder) + err = cmv1.MarshalExternalAuth(provider, b) + Expect(err).ToNot(HaveOccurred()) + return b.String() + } + Context("static validation", func() { + It("fails if cluster ID is empty", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("cluster ID may not be empty/blank string") + }) + + It("fails if provider ID is empty", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("provider ID may not be empty/blank string") + }) + + It("fails if issuer URL is not HTTPS", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "http://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("issuer URL must use HTTPS") + }) + + It("fails if client ID is provided but secret is missing", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [ + { + id = "client-123" + # secret missing + } + ] + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("Client secret is required when client ID is provided") + }) + + It("succeeds when client ID and secret are both provided", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [ + { + id = "client-123" + secret = "client-secret" + } + ] + }`) + Expect(Terraform.Validate().ExitCode).To(BeZero()) + }) + + It("succeeds when client has no ID and no secret", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [ + { + component = { + name = "test-component" + namespace = "test-namespace" + } + } + ] + }`) + Expect(Terraform.Validate().ExitCode).To(BeZero()) + }) + + It("validates validation rules have both claim and required_value", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + claim = { + validation_rules = [ + { + claim = "test-claim" + required_value = "test-value" + } + ] + } + }`) + Expect(Terraform.Validate().ExitCode).To(BeZero()) + }) + + It("accepts minimal valid configuration", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + Expect(Terraform.Validate().ExitCode).To(BeZero()) + }) + + It("accepts complex valid configuration", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://auth.example.com/oauth2" + audiences = ["audience1", "audience2"] + ca = "-----BEGIN CERTIFICATE-----\nMIIC..." + } + clients = [ + { + component = { + name = "console" + namespace = "openshift-console" + } + id = "console-client" + secret = "super-secret" + extra_scopes = ["openid", "profile"] + } + ] + claim = { + mappings = { + username = { + claim = "preferred_username" + prefix = "ext:" + prefix_policy = "NoPrefix" + } + groups = { + claim = "groups" + prefix = "ext-group:" + } + } + validation_rules = [ + { + claim = "aud" + required_value = "my-app" + }, + { + claim = "iss" + required_value = "https://auth.example.com" + } + ] + } + }`) + Expect(Terraform.Validate().ExitCode).To(BeZero()) + }) + + It("validates multiple clients with different configurations", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [ + { + id = "confidential-client" + secret = "client-secret" + }, + { + component = { + name = "public-component" + namespace = "default" + } + } + ] + }`) + Expect(Terraform.Validate().ExitCode).To(BeZero()) + }) + + It("fails with multiple clients where one has ID but no secret", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [ + { + id = "confidential-client" + secret = "client-secret" + }, + { + id = "invalid-client" + # secret missing for this client + } + ] + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("Client secret is required when client ID is provided") + }) + }) + + Context("runtime validation", func() { + It("fails when external auth is not enabled on cluster", func() { + // Mock cluster calls for validation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testClusterRoute), + RespondWithJSON(http.StatusOK, clusterWithoutExternalAuthTemplate()), + ), + ) + + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("External authentication configuration is not enabled") + }) + + It("fails with invalid cluster ID", func() { + // Mock 404 for non-existent cluster + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, "/api/clusters_mgmt/v1/clusters/non-existent-cluster"), + RespondWithJSON(http.StatusNotFound, `{"kind": "Error", "code": "CLUSTERS-MGMT-404", "reason": "Cluster 'non-existent-cluster' not found"}`), + ), + ) + + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "non-existent-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + // The error message may vary depending on cluster state check + }) + }) + + Context("import functionality", func() { + It("fails with invalid import format", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Import("rhcs_external_auth_provider.test", "invalid-format") + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("External auth provider to import should be specified as ,") + }) + + It("fails with missing cluster ID", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Import("rhcs_external_auth_provider.test", ",provider-id") + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("External auth provider to import should be specified as ,") + }) + + It("fails with missing provider ID", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Import("rhcs_external_auth_provider.test", "cluster-id,") + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("External auth provider to import should be specified as ,") + }) + + It("succeeds with valid import format", func() { + TestServer.AppendHandlers( + // Mock external auth provider retrieval + CombineHandlers( + VerifyRequest(http.MethodGet, testProviderRoute), + RespondWithJSON(http.StatusOK, externalAuthProviderTemplate()), + ), + ) + + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" {}`) + runOutput := Terraform.Import("rhcs_external_auth_provider.test", "test-cluster,test-provider") + Expect(runOutput.ExitCode).To(BeZero()) + }) + }) + + Context("configuration edge cases", func() { + It("handles empty audiences list", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = [] + } + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("handles single audience", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["single-audience"] + } + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("handles multiple audiences", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1", "audience2", "audience3"] + } + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("validates complex claim mappings", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://auth.example.com" + audiences = ["audience1"] + } + claim = { + mappings = { + username = { + claim = "sub" + prefix = "external:" + prefix_policy = "Prefix" + } + groups = { + claim = "groups" + prefix = "external-group:" + } + } + validation_rules = [ + { + claim = "aud" + required_value = "my-application" + }, + { + claim = "iss" + required_value = "https://auth.example.com" + }, + { + claim = "exp" + required_value = "3600" + } + ] + } + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("validates empty claim configuration", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + claim = {} + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("validates client with component only", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [ + { + component = { + name = "console" + namespace = "openshift-console" + } + } + ] + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("validates client with extra scopes", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [ + { + id = "my-client" + secret = "my-secret" + extra_scopes = ["openid", "profile", "email", "groups"] + } + ] + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("validates mixed client configurations", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [ + { + id = "confidential-client" + secret = "confidential-secret" + extra_scopes = ["openid", "profile"] + }, + { + component = { + name = "public-component" + namespace = "openshift-auth" + } + extra_scopes = ["openid"] + }, + { + id = "another-client" + secret = "another-secret" + } + ] + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + }) + + Context("URL validation", func() { + It("fails with invalid protocol", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "ftp://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("issuer URL must use HTTPS") + }) + + It("fails with HTTP instead of HTTPS", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "http://insecure.example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).ToNot(BeZero()) + runOutput.VerifyErrorContainsSubstring("issuer URL must use HTTPS") + }) + + It("succeeds with HTTPS URL with path", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://auth.example.com/oauth2/default" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("succeeds with HTTPS URL with port", func() { + Terraform.Source(` + resource "rhcs_external_auth_provider" "my_provider" { + cluster = "test-cluster" + id = "my-provider" + issuer = { + url = "https://auth.example.com:8443/oauth2" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Validate() + Expect(runOutput.ExitCode).To(BeZero()) + }) + }) + + Context("CRUD operations", func() { + It("creates external auth provider successfully", func() { + // Mock cluster with external auth enabled (for validation) + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testClusterRoute), + RespondWithJSON(http.StatusOK, clusterWithExternalAuthTemplate()), + ), + ) + // Mock successful provider creation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodPost, testExternalAuthRoute), + VerifyJQ(".id", "test-provider"), + VerifyJQ(".issuer.url", "https://example.com"), + VerifyJQ(".issuer.audiences[0]", "audience1"), + RespondWithJSON(http.StatusCreated, externalAuthProviderTemplate()), + ), + ) + // Mock provider read after creation (for state refresh) + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testProviderRoute), + RespondWithJSON(http.StatusOK, externalAuthProviderTemplate()), + ), + ) + + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).To(BeZero()) + + // Verify resource was created with correct attributes + resource := Terraform.Resource("rhcs_external_auth_provider", "test") + Expect(resource).To(MatchJQ(".attributes.id", "test-provider")) + Expect(resource).To(MatchJQ(".attributes.cluster", "test-cluster")) + Expect(resource).To(MatchJQ(".attributes.issuer.url", "https://example.com")) + Expect(resource).To(MatchJQ(".attributes.issuer.audiences[0]", "audience1")) + }) + + It("reads external auth provider state correctly", func() { + // Mock cluster with external auth enabled + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testClusterRoute), + RespondWithJSON(http.StatusOK, clusterWithExternalAuthTemplate()), + ), + ) + // Mock successful provider creation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodPost, testExternalAuthRoute), + RespondWithJSON(http.StatusCreated, externalAuthProviderTemplate()), + ), + ) + // Mock provider read after creation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testProviderRoute), + RespondWithJSON(http.StatusOK, externalAuthProviderTemplate()), + ), + ) + + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + + // Apply to create + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).To(BeZero()) + + // Verify resource state is populated correctly + resource := Terraform.Resource("rhcs_external_auth_provider", "test") + Expect(resource).To(MatchJQ(".attributes.id", "test-provider")) + Expect(resource).To(MatchJQ(".attributes.cluster", "test-cluster")) + }) + + It("updates external auth provider successfully", func() { + // Mock cluster with external auth enabled + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testClusterRoute), + RespondWithJSON(http.StatusOK, clusterWithExternalAuthTemplate()), + ), + ) + // Mock successful provider creation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodPost, testExternalAuthRoute), + RespondWithJSON(http.StatusCreated, externalAuthProviderTemplate()), + ), + ) + // Mock provider read after creation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testProviderRoute), + RespondWithJSON(http.StatusOK, externalAuthProviderTemplate()), + ), + ) + // Mock successful provider update + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodPatch, testProviderRoute), + VerifyJQ(".issuer.url", "https://updated-example.com"), + VerifyJQ(".issuer.audiences[0]", "audience1"), + VerifyJQ(".issuer.audiences[1]", "audience2"), + RespondWithJSON(http.StatusOK, updatedExternalAuthProviderTemplate()), + ), + ) + // Mock provider read after update + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testProviderRoute), + RespondWithJSON(http.StatusOK, updatedExternalAuthProviderTemplate()), + ), + ) + + // Apply initial config + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).To(BeZero()) + + // Apply updated config + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://updated-example.com" + audiences = ["audience1", "audience2"] + } + }`) + runOutput = Terraform.Apply() + Expect(runOutput.ExitCode).To(BeZero()) + + // Verify updates were applied + resource := Terraform.Resource("rhcs_external_auth_provider", "test") + Expect(resource).To(MatchJQ(".attributes.issuer.url", "https://updated-example.com")) + Expect(resource).To(MatchJQ(".attributes.issuer.audiences | length", 2)) + }) + + It("deletes external auth provider successfully", func() { + // Mock cluster with external auth enabled + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testClusterRoute), + RespondWithJSON(http.StatusOK, clusterWithExternalAuthTemplate()), + ), + ) + // Mock successful provider creation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodPost, testExternalAuthRoute), + RespondWithJSON(http.StatusCreated, externalAuthProviderTemplate()), + ), + ) + // Mock provider read after creation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testProviderRoute), + RespondWithJSON(http.StatusOK, externalAuthProviderTemplate()), + ), + ) + // Mock successful provider deletion + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodDelete, testProviderRoute), + RespondWithJSON(http.StatusNoContent, ""), + ), + ) + + // Apply config to create provider + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + }`) + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).To(BeZero()) + + // Destroy the provider + runOutput = Terraform.Destroy() + Expect(runOutput.ExitCode).To(BeZero()) + }) + + It("creates external auth provider with console client", func() { + // Mock cluster with external auth enabled + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testClusterRoute), + RespondWithJSON(http.StatusOK, clusterWithExternalAuthTemplate()), + ), + ) + // Mock successful provider creation with console client + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodPost, testExternalAuthRoute), + VerifyJQ(".id", "test-provider"), + VerifyJQ(".issuer.url", "https://example.com"), + VerifyJQ(".clients[0].id", "console-client"), + VerifyJQ(".clients[0].secret", "console-secret"), + RespondWithJSON(http.StatusCreated, externalAuthProviderWithConsoleTemplate()), + ), + ) + // Mock provider read after creation + TestServer.AppendHandlers( + CombineHandlers( + VerifyRequest(http.MethodGet, testProviderRoute), + RespondWithJSON(http.StatusOK, externalAuthProviderWithConsoleTemplate()), + ), + ) + + Terraform.Source(` + resource "rhcs_external_auth_provider" "test" { + cluster = "test-cluster" + id = "test-provider" + issuer = { + url = "https://example.com" + audiences = ["audience1"] + } + clients = [{ + id = "console-client" + secret = "console-secret" + }] + }`) + + runOutput := Terraform.Apply() + Expect(runOutput.ExitCode).To(BeZero()) + + // Verify resource was created with console client + resource := Terraform.Resource("rhcs_external_auth_provider", "test") + Expect(resource).To(MatchJQ(".attributes.clients[0].id", "console-client")) + Expect(resource).To(MatchJQ(".attributes.clients[0].secret", "console-secret")) + }) + }) +})