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"))
+ })
+ })
+})