diff --git a/.changelog/0.14.0.toml b/.changelog/0.14.0.toml index fa2e8545..f2afb864 100644 --- a/.changelog/0.14.0.toml +++ b/.changelog/0.14.0.toml @@ -3,6 +3,8 @@ title = "" description = "" [[features]] +title = "New resource" +description = "`oxide_silo_saml_identity_provider` [#442](https://github.com/oxidecomputer/terraform-provider-oxide/pull/442)." [[enhancements]] title = "" diff --git a/docs/resources/oxide_silo_saml_identity_provider.md b/docs/resources/oxide_silo_saml_identity_provider.md new file mode 100644 index 00000000..e88ab6e0 --- /dev/null +++ b/docs/resources/oxide_silo_saml_identity_provider.md @@ -0,0 +1,115 @@ +--- +page_title: "oxide_silo_saml_identity_provider Resource - terraform-provider-oxide" +--- + +# oxide_silo_saml_identity_provider (Resource) + +Manages a SAML identity provider (IdP) for an Oxide silo. + +-> This resource does not support updates. All attributes are immutable once +created. + +-> This resource does not support deletion from the Oxide API. When destroyed in +Terraform, it will be removed from state but will continue to exist in Oxide. + +## Example Usage + +### With URL Metadata Source + +```hcl +resource "oxide_silo_saml_identity_provider" "example" { + silo = oxide_silo.example.id + name = "keycloak" + description = "Managed by Terraform." + group_attribute_name = "groups" + idp_entity_id = "https://keycloak.example.com/realms/oxide" + acs_url = "https://example.com/saml/acs" + slo_url = "https://example.com/saml/logout" + sp_client_id = "oxide-sp" + technical_contact_email = "admin@example.com" + + idp_metadata_source = { + type = "url" + url = "https://keycloak.example.com/realms/oxide/protocol/saml/descriptor" + } +} +``` + +### With Base64-Encoded XML Metadata + +```hcl +resource "oxide_silo_saml_identity_provider" "example" { + silo = oxide_silo.example.id + name = "custom-idp" + description = "Custom SAML identity provider" + idp_entity_id = "https://idp.example.com" + acs_url = "https://example.com/saml/acs" + slo_url = "https://example.com/saml/logout" + sp_client_id = "oxide-sp" + technical_contact_email = "admin@example.com" + + idp_metadata_source = { + type = "base64_encoded_xml" + data = base64encode(file("${path.module}/idp-metadata.xml")) + } + + signing_keypair = { + private_key = base64encode(file("${path.module}/saml-key.pem")) + public_cert = base64encode(file("${path.module}/saml-cert.pem")) + } +} +``` + +## Schema + +### Required + +- `acs_url` (String) URL where the identity provider should send the SAML response. +- `description` (String) Free-form text describing the SAML identity provider. +- `idp_entity_id` (String) Identity provider's entity ID. +- `idp_metadata_source` (Attributes) Source of identity provider metadata (URL or base64-encoded XML). (see [below for nested schema](#nestedatt--idp_metadata_source)) +- `name` (String) Unique, immutable, user-controlled identifier of the SAML identity provider. Maximum length is 63 characters. +- `silo` (String) Name or ID of the silo. +- `slo_url` (String) URL where the identity provider should send logout requests. +- `sp_client_id` (String) Service provider's client ID. +- `technical_contact_email` (String) Technical contact email for SAML configuration. + +### Optional + +- `group_attribute_name` (String) SAML attribute that holds a user's group membership. +- `signing_keypair` (Attributes) RSA private key and public certificate for signing SAML requests. (see [below for nested schema](#nestedatt--signing_keypair)) +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) + +### Read-Only + +- `id` (String) Unique, immutable, system-controlled identifier of the SAML identity provider. +- `time_created` (String) Timestamp of when this SAML identity provider was created. +- `time_modified` (String) Timestamp of when this SAML identity provider was last modified. + + +### Nested Schema for `idp_metadata_source` + +Required: + +- `type` (String) The type of metadata source. Must be one of: `url`, `base64_encoded_xml`. + +Optional: + +- `data` (String) Base64-encoded XML metadata (required when type is `base64_encoded_xml`). Conflicts with `url`. +- `url` (String) URL to fetch metadata from (required when type is `url`). Conflicts with `data`. + + +### Nested Schema for `signing_keypair` + +Required: + +- `private_key` (String, Sensitive) RSA private key (base64 encoded). +- `public_cert` (String) Public certificate (base64 encoded). + + +### Nested Schema for `timeouts` + +Optional: + +- `create` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). +- `read` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). diff --git a/docs/resources/oxide_vpc_firewall_rules.md b/docs/resources/oxide_vpc_firewall_rules.md index 09a21170..4636c169 100644 --- a/docs/resources/oxide_vpc_firewall_rules.md +++ b/docs/resources/oxide_vpc_firewall_rules.md @@ -70,12 +70,12 @@ resource "oxide_vpc_firewall_rules" "example" { }, # Echo Reply types. { - type = "icmp", + type = "icmp", icmp_type = 0 }, # Echo Reply types with codes 1-3. { - type = "icmp", + type = "icmp", icmp_type = 0 icmp_code = "1-3" }, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 62f1f228..7cf1358f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -199,5 +199,6 @@ func (p *oxideProvider) Resources(_ context.Context) []func() resource.Resource NewVPCSubnetResource, NewFloatingIPResource, NewSiloResource, + NewSiloSamlIdentityProviderResource, } } diff --git a/internal/provider/resource_silo_saml_identity_provider.go b/internal/provider/resource_silo_saml_identity_provider.go new file mode 100644 index 00000000..f4cdc619 --- /dev/null +++ b/internal/provider/resource_silo_saml_identity_provider.go @@ -0,0 +1,331 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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" + "github.com/oxidecomputer/oxide.go/oxide" +) + +var ( + _ resource.Resource = (*siloSamlIdentityProvider)(nil) + _ resource.ResourceWithConfigure = (*siloSamlIdentityProvider)(nil) +) + +func NewSiloSamlIdentityProviderResource() resource.Resource { + return &siloSamlIdentityProvider{} +} + +type siloSamlIdentityProvider struct { + client *oxide.Client +} + +type siloSamlIdentityProviderResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Silo types.String `tfsdk:"silo"` + AcsUrl types.String `tfsdk:"acs_url"` + IdpEntityId types.String `tfsdk:"idp_entity_id"` + SloUrl types.String `tfsdk:"slo_url"` + SpClientId types.String `tfsdk:"sp_client_id"` + TechnicalContactEmail types.String `tfsdk:"technical_contact_email"` + IdpMetadataSource *siloSamlIdentityProviderMetadataSourceModel `tfsdk:"idp_metadata_source"` + GroupAttributeName types.String `tfsdk:"group_attribute_name"` + SigningKeypair *siloSamlIdentityProviderSigningKeypairModel `tfsdk:"signing_keypair"` + TimeCreated types.String `tfsdk:"time_created"` + TimeModified types.String `tfsdk:"time_modified"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +type siloSamlIdentityProviderSigningKeypairModel struct { + PrivateKey types.String `tfsdk:"private_key"` + PublicCert types.String `tfsdk:"public_cert"` +} + +type siloSamlIdentityProviderMetadataSourceModel struct { + Type types.String `tfsdk:"type"` + Url types.String `tfsdk:"url"` + Data types.String `tfsdk:"data"` +} + +func (r *siloSamlIdentityProvider) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "oxide_silo_saml_identity_provider" +} + +func (r *siloSamlIdentityProvider) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + r.client = req.ProviderData.(*oxide.Client) +} + +func (r *siloSamlIdentityProvider) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a SAML identity provider (IdP) for an Oxide silo.", + Attributes: map[string]schema.Attribute{ + "silo": schema.StringAttribute{ + Required: true, + Description: "Name or ID of the silo.", + }, + "acs_url": schema.StringAttribute{ + Required: true, + Description: "URL where the identity provider should send the SAML response.", + }, + "description": schema.StringAttribute{ + Required: true, + Description: "Free-form text describing the SAML identity provider.", + }, + "group_attribute_name": schema.StringAttribute{ + Optional: true, + Description: "SAML attribute that holds a user's group membership.", + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "Unique, immutable, system-controlled identifier of the SAML identity provider.", + }, + "idp_entity_id": schema.StringAttribute{ + Required: true, + Description: "Identity provider's entity ID.", + }, + "idp_metadata_source": schema.SingleNestedAttribute{ + Required: true, + Description: "Source of identity provider metadata (URL or base64-encoded XML).", + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf( + "url", + "base64_encoded_xml", + ), + }, + }, + "url": schema.StringAttribute{ + Optional: true, + Description: "URL to fetch metadata from (required when type is `url`).", + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("data")), + }, + }, + "data": schema.StringAttribute{ + Optional: true, + Description: "Base64-encoded XML metadata (required when type is `base64_encoded_xml`).", + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("url")), + }, + }, + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Unique, immutable, user-controlled identifier of the SAML identity provider.", + Validators: []validator.String{ + stringvalidator.LengthAtMost(63), + }, + }, + "signing_keypair": schema.SingleNestedAttribute{ + Optional: true, + Description: "RSA private key and public certificate for signing SAML requests.", + Attributes: map[string]schema.Attribute{ + "private_key": schema.StringAttribute{ + Required: true, + Sensitive: true, + Description: "RSA private key (base64 encoded).", + }, + "public_cert": schema.StringAttribute{ + Required: true, + Description: "Public certificate (base64 encoded).", + }, + }, + }, + "slo_url": schema.StringAttribute{ + Required: true, + Description: "URL where the identity provider should send logout requests.", + }, + "sp_client_id": schema.StringAttribute{ + Required: true, + Description: "Service provider's client ID.", + }, + "technical_contact_email": schema.StringAttribute{ + Required: true, + Description: "Technical contact email for SAML configuration.", + }, + "time_created": schema.StringAttribute{ + Computed: true, + Description: "Timestamp of when this SAML identity provider was created.", + }, + "time_modified": schema.StringAttribute{ + Computed: true, + Description: "Timestamp of when this SAML identity provider was last modified.", + }, + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + Read: true, + }), + }, + } +} + +func (r *siloSamlIdentityProvider) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan siloSamlIdentityProviderResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + createTimeout, diags := plan.Timeouts.Create(ctx, defaultTimeout()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx, cancel := context.WithTimeout(ctx, createTimeout) + defer cancel() + + idpMetadataSource := oxide.IdpMetadataSource{ + Type: oxide.IdpMetadataSourceType(plan.IdpMetadataSource.Type.ValueString()), + } + + switch idpMetadataSource.Type { + case oxide.IdpMetadataSourceTypeBase64EncodedXml: + idpMetadataSource.Data = plan.IdpMetadataSource.Data.ValueString() + case oxide.IdpMetadataSourceTypeUrl: + idpMetadataSource.Url = plan.IdpMetadataSource.Url.ValueString() + } + + params := oxide.SamlIdentityProviderCreateParams{ + Silo: oxide.NameOrId(plan.Silo.ValueString()), + Body: &oxide.SamlIdentityProviderCreate{ + AcsUrl: plan.AcsUrl.ValueString(), + IdpEntityId: plan.IdpEntityId.ValueString(), + Name: oxide.Name(plan.Name.ValueString()), + SloUrl: plan.SloUrl.ValueString(), + SpClientId: plan.SpClientId.ValueString(), + TechnicalContactEmail: plan.TechnicalContactEmail.ValueString(), + IdpMetadataSource: idpMetadataSource, + Description: plan.Description.ValueString(), + GroupAttributeName: plan.GroupAttributeName.ValueString(), + }, + } + + if plan.SigningKeypair != nil { + params.Body.SigningKeypair = oxide.DerEncodedKeyPair{ + PrivateKey: plan.SigningKeypair.PrivateKey.ValueString(), + PublicCert: plan.SigningKeypair.PublicCert.ValueString(), + } + } + + idpConfig, err := r.client.SamlIdentityProviderCreate(ctx, params) + if err != nil { + resp.Diagnostics.AddError( + "Error creating SAML identity provider", + "API error: "+err.Error(), + ) + return + } + + tflog.Trace(ctx, fmt.Sprintf("created SAML identity provider with ID: %v", idpConfig.Id), map[string]any{"success": true}) + + plan.ID = types.StringValue(idpConfig.Id) + plan.TimeCreated = types.StringValue(idpConfig.TimeCreated.String()) + plan.TimeModified = types.StringValue(idpConfig.TimeModified.String()) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *siloSamlIdentityProvider) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state siloSamlIdentityProviderResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + readTimeout, diags := state.Timeouts.Read(ctx, defaultTimeout()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx, cancel := context.WithTimeout(ctx, readTimeout) + defer cancel() + + params := oxide.SamlIdentityProviderViewParams{ + Provider: oxide.NameOrId(state.ID.ValueString()), + } + + idpConfig, err := r.client.SamlIdentityProviderView(ctx, params) + if err != nil { + if is404(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Unable to read SAML identity provider:", + "API error: "+err.Error(), + ) + return + } + + tflog.Trace(ctx, fmt.Sprintf("read SAML identity provider with ID: %v", idpConfig.Id), map[string]any{"success": true}) + + state.ID = types.StringValue(idpConfig.Id) + state.TimeCreated = types.StringValue(idpConfig.TimeCreated.String()) + state.TimeModified = types.StringValue(idpConfig.TimeModified.String()) + state.AcsUrl = types.StringValue(idpConfig.AcsUrl) + state.Description = types.StringValue(idpConfig.Description) + state.GroupAttributeName = types.StringValue(idpConfig.GroupAttributeName) + state.IdpEntityId = types.StringValue(idpConfig.IdpEntityId) + state.Name = types.StringValue(string(idpConfig.Name)) + state.SloUrl = types.StringValue(idpConfig.SloUrl) + state.SpClientId = types.StringValue(idpConfig.SpClientId) + state.TechnicalContactEmail = types.StringValue(idpConfig.TechnicalContactEmail) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update is purposefully unsupported since there's no upstream API to update a +// silo's SAML identity provider configuration. +// +// An error is added to the diagnostics so that Terraform will stop execution +// and prompt the user to fix their configuration. +func (r *siloSamlIdentityProvider) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError( + "The oxide_silo_saml_identity_provider resource does not support updates.", + "This resource represents immutable silo SAML identity provider configuration. Please update your Terraform configuration to match the state.", + ) +} + +// Delete is purposefully unsupported since there's no upstream API to delete a +// silo's SAML identity provider configuration. +// +// A warning is added to the diagnostics so that the resource will be removed +// from the state and Terraform execution will continue and prompt the user on +// what happened. +func (r *siloSamlIdentityProvider) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddWarning( + "The oxide_silo_saml_identity_provider resource does not support deletion.", + "This resource represents immutable silo SAML identity provider configuration. The resource will be removed from Terraform state but not from Oxide.", + ) +} diff --git a/internal/provider/resource_silo_saml_identity_provider_test.go b/internal/provider/resource_silo_saml_identity_provider_test.go new file mode 100644 index 00000000..4bf4e6be --- /dev/null +++ b/internal/provider/resource_silo_saml_identity_provider_test.go @@ -0,0 +1,192 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package provider + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/oxidecomputer/oxide.go/oxide" +) + +type resourceSiloIdentifyProviderConfig struct { + SiloBlockName string + SiloDNSName string + SiloName string + SiloSamlIdentityProviderBlockName string + SiloSamlIdentityProviderName string +} + +var resourceSiloIdentityProviderConfigTpl = ` +resource "tls_private_key" "self-signed" { + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_self_signed_cert" "self-signed" { + private_key_pem = tls_private_key.self-signed.private_key_pem + validity_period_hours = 8760 + + subject { + common_name = "{{.SiloDNSName}}" + organization = "Oxide Computer Company" + } + + dns_names = ["{{.SiloDNSName}}"] + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +resource "oxide_silo" "{{.SiloBlockName}}" { + name = "{{.SiloName}}" + description = "Managed by Terraform." + discoverable = true + identity_mode = "saml_jit" + admin_group_name = "admin" + + quotas = { + cpus = 2 + memory = 137438953472 + storage = 549755813888 + } + + mapped_fleet_roles = { + admin = ["admin", "collaborator"] + viewer = ["viewer"] + } + + tls_certificates = [ + { + name = "self-signed-wildcard" + description = "Self-signed wildcard certificate for {{.SiloDNSName}}" + cert = tls_self_signed_cert.self-signed.cert_pem + key = tls_private_key.self-signed.private_key_pem + service = "external_api" + }, + ] +} + +resource "oxide_silo_saml_identity_provider" "{{.SiloSamlIdentityProviderBlockName}}" { + silo = oxide_silo.{{.SiloBlockName}}.id + name = "{{.SiloSamlIdentityProviderName}}" + description = "Managed by Terraform." + group_attribute_name = "example" + idp_entity_id = "example" + acs_url = "https://example.com" + slo_url = "https://example.com" + sp_client_id = "example" + technical_contact_email = "example@example.com" + + idp_metadata_source = { + type = "base64_encoded_xml" + data = "PG1kOkVudGl0eURlc2NyaXB0b3IKCXhtbG5zPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bWV0YWRhdGEiCgl4bWxuczptZD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm1ldGFkYXRhIgoJeG1sbnM6c2FtbD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiIKCXhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIiBlbnRpdHlJRD0iaHR0cHM6Ly9leGFtcGxlLmNvbSI+Cgk8bWQ6SURQU1NPRGVzY3JpcHRvciBXYW50QXV0aG5SZXF1ZXN0c1NpZ25lZD0idHJ1ZSIgcHJvdG9jb2xTdXBwb3J0RW51bWVyYXRpb249InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCI+CgkJPG1kOktleURlc2NyaXB0b3IgdXNlPSJzaWduaW5nIj4KCQkJPGRzOktleUluZm8+CgkJCQk8ZHM6S2V5TmFtZT5xWlc3N3I2Vy1NQVhCQURPWkdfb0lVeGlWSmhzWHJGa2tEUlFlQWREYzhjPC9kczpLZXlOYW1lPgoJCQkJPGRzOlg1MDlEYXRhPgoJCQkJCTxkczpYNTA5Q2VydGlmaWNhdGU+TUlJQ3VUQ0NBYUVDQmdHVUdOYUluekFOQmdrcWhraUc5dzBCQVFzRkFEQWdNUjR3SEFZRFZRUUREQlZrWlcxdkxUQXdaakF6WWpoaFpEUmpaVFExT0RJd0hoY05NalF4TWpNd01UZ3pNREF3V2hjTk16UXhNak13TVRnek1UUXdXakFnTVI0d0hBWURWUVFEREJWa1pXMXZMVEF3WmpBellqaGhaRFJqWlRRMU9ESXdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFDc04yR2Y4Z040b0hHSVI3NXZTaDBZakc0ejFyNytLSGx2cG84QnZmRm9hVk5QR255NHNOMVJGdlI5V25pOVMvM0lXRHNjaDV3NTZnMnk3MFNYcmloUWVKZUptUHhucVd1cUFuSDVLeUgxcjFZeVNqK3pHRGJpRHJyM3pBNlYvdFErUHlJZ0R1cUEvaGg1cmxoRndwOEdQRndnZFBCNTEyK2x5MmR5bTkzQ1BrdDdTdk1KQXhlOHFWYWZPTU9nVEIzcUdiT25jSDdYd1BMMnlhTUhKYUlsTFMwSHh5Ti81S1RrUk5aZERwb25JTFYvajlZT2hZSDdJRDl3c3Y2dlR2NnM3Y3BST0dPMmdFOUVPM1pzTTlwUWlxMjN0RGlTUjloY3BvT2piOElyc0VzMXYxZDlkUGRTV2xybSt4L0U1THlZb1VVT1RUdnlpWDdrU0dPVVFMbS9BZ01CQUFFd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFHTGhBSXlITURVSk9QNkttNzRIYjhUSncxdVh4bVJXRjBRcDBza1BtcUxFSEdRYVpic0R4YVBNYzR0ZDI2TUY0R2dMZ2FNZXVnQlkwZk12d0ErMGJDR0EwY2hLVWpJQUwybEg0UGpzVG15cGliVWMySDFlU1BsOTNoYlBzaTFPSm85bTRvblVHcmg3Z3hHWWJ5Tm85R0tBemgvMmZyZVNRbE82K1pmZkdmSlhabEtTT3pnangzcUVYOTc1N2t1Q3VYWVdtQ3hGbmROd0h2ZjdOTVJENUlQOHRYeEN4a09sbUFBZjRKbUlBbnNsNVp1KzR6K0NuZE1vNkYxMjFsT0t4Tkt2Y0ZYaHFQaHJyd1krZlFreEpXdWprVGp2dTRTN1FsM0dOMzVDWURZdDg5a2drL212VmVCaHVRd1pBR3dWRzE3RnlsN3BNRlFyTGtUQ2ErQmJyY1U9PC9kczpYNTA5Q2VydGlmaWNhdGU+CgkJCQk8L2RzOlg1MDlEYXRhPgoJCQk8L2RzOktleUluZm8+CgkJPC9tZDpLZXlEZXNjcmlwdG9yPgoJCTxtZDpBcnRpZmFjdFJlc29sdXRpb25TZXJ2aWNlIEJpbmRpbmc9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpiaW5kaW5nczpTT0FQIiBMb2NhdGlvbj0iaHR0cHM6Ly9leGFtcGxlLmNvbSIgaW5kZXg9IjAiLz4KCQk8bWQ6U2luZ2xlTG9nb3V0U2VydmljZSBCaW5kaW5nPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YmluZGluZ3M6SFRUUC1QT1NUIiBMb2NhdGlvbj0iaHR0cHM6Ly9leGFtcGxlLmNvbSIvPgoJCTxtZDpTaW5nbGVMb2dvdXRTZXJ2aWNlIEJpbmRpbmc9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpiaW5kaW5nczpIVFRQLVJlZGlyZWN0IiBMb2NhdGlvbj0iaHR0cHM6Ly9leGFtcGxlLmNvbSIvPgoJCTxtZDpTaW5nbGVMb2dvdXRTZXJ2aWNlIEJpbmRpbmc9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpiaW5kaW5nczpIVFRQLUFydGlmYWN0IiBMb2NhdGlvbj0iaHR0cHM6Ly9leGFtcGxlLmNvbSIvPgoJCTxtZDpTaW5nbGVMb2dvdXRTZXJ2aWNlIEJpbmRpbmc9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpiaW5kaW5nczpTT0FQIiBMb2NhdGlvbj0iaHR0cHM6Ly9leGFtcGxlLmNvbSIvPgoJCTxtZDpOYW1lSURGb3JtYXQ+dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6cGVyc2lzdGVudDwvbWQ6TmFtZUlERm9ybWF0PgoJCTxtZDpOYW1lSURGb3JtYXQ+dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6dHJhbnNpZW50PC9tZDpOYW1lSURGb3JtYXQ+CgkJPG1kOk5hbWVJREZvcm1hdD51cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoxLjE6bmFtZWlkLWZvcm1hdDp1bnNwZWNpZmllZDwvbWQ6TmFtZUlERm9ybWF0PgoJCTxtZDpOYW1lSURGb3JtYXQ+dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6MS4xOm5hbWVpZC1mb3JtYXQ6ZW1haWxBZGRyZXNzPC9tZDpOYW1lSURGb3JtYXQ+CgkJPG1kOlNpbmdsZVNpZ25PblNlcnZpY2UgQmluZGluZz0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmJpbmRpbmdzOkhUVFAtUE9TVCIgTG9jYXRpb249Imh0dHBzOi8vZXhhbXBsZS5jb20iLz4KCQk8bWQ6U2luZ2xlU2lnbk9uU2VydmljZSBCaW5kaW5nPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YmluZGluZ3M6SFRUUC1SZWRpcmVjdCIgTG9jYXRpb249Imh0dHBzOi8vZXhhbXBsZS5jb20iLz4KCQk8bWQ6U2luZ2xlU2lnbk9uU2VydmljZSBCaW5kaW5nPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YmluZGluZ3M6U09BUCIgTG9jYXRpb249Imh0dHBzOi8vZXhhbXBsZS5jb20iLz4KCQk8bWQ6U2luZ2xlU2lnbk9uU2VydmljZSBCaW5kaW5nPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YmluZGluZ3M6SFRUUC1BcnRpZmFjdCIgTG9jYXRpb249Imh0dHBzOi8vZXhhbXBsZS5jb20iLz4KCTwvbWQ6SURQU1NPRGVzY3JpcHRvcj4KPC9tZDpFbnRpdHlEZXNjcmlwdG9yPgo=" + } +} +` + +func TestAccSiloResourceSiloSamlIdentityProvider_full(t *testing.T) { + siloBlockName := newBlockName("silo") + siloName := newResourceName() + siloSamlIdentityProviderBlockName := newBlockName("silo-idp") + siloSamlIdentityProviderName := newResourceName() + + siloSamlIdentityProviderResourceID := fmt.Sprintf("oxide_silo_saml_identity_provider.%s", siloSamlIdentityProviderBlockName) + + siloDNSName := os.Getenv("OXIDE_SILO_DNS_NAME") + if siloDNSName == "" { + t.Skip("Skipping test. Export OXIDE_SILO_DNS_NAME to run.") + } + + config, err := parsedAccConfig( + resourceSiloIdentifyProviderConfig{ + SiloBlockName: siloBlockName, + SiloDNSName: siloDNSName, + SiloName: siloName, + SiloSamlIdentityProviderBlockName: siloSamlIdentityProviderBlockName, + SiloSamlIdentityProviderName: siloSamlIdentityProviderName, + }, + resourceSiloIdentityProviderConfigTpl, + ) + if err != nil { + t.Errorf("error parsing config template data: %e", err) + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories(), + ExternalProviders: map[string]resource.ExternalProvider{ + "tls": { + Source: "hashicorp/tls", + }, + }, + CheckDestroy: testAccSiloSamlIdentityProviderDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: checkResourceSiloSamlIdentityProvider(siloSamlIdentityProviderResourceID, siloSamlIdentityProviderName), + }, + }, + }) +} + +func checkResourceSiloSamlIdentityProvider(resourceID string, nameAttr string) resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc([]resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet(resourceID, "id"), + resource.TestCheckResourceAttr(resourceID, "name", nameAttr), + resource.TestCheckResourceAttr(resourceID, "description", "Managed by Terraform."), + resource.TestCheckResourceAttrSet(resourceID, "silo"), + resource.TestCheckResourceAttr(resourceID, "group_attribute_name", "example"), + resource.TestCheckResourceAttr(resourceID, "idp_entity_id", "example"), + resource.TestCheckResourceAttr(resourceID, "acs_url", "https://example.com"), + resource.TestCheckResourceAttr(resourceID, "slo_url", "https://example.com"), + resource.TestCheckResourceAttr(resourceID, "sp_client_id", "example"), + resource.TestCheckResourceAttr(resourceID, "technical_contact_email", "example@example.com"), + resource.TestCheckResourceAttr(resourceID, "idp_metadata_source.type", "base64_encoded_xml"), + resource.TestCheckResourceAttrSet(resourceID, "idp_metadata_source.data"), + }...) +} + +func testAccSiloSamlIdentityProviderDestroy(s *terraform.State) error { + client, err := newTestClient() + if err != nil { + return err + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "oxide_silo_saml_identity_provider" { + continue + } + + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + params := oxide.SamlIdentityProviderViewParams{ + Provider: oxide.NameOrId(rs.Primary.Attributes["id"]), + } + + res, err := client.SamlIdentityProviderView(ctx, params) + if err != nil && is404(err) { + continue + } + + return fmt.Errorf("silo saml identity provider (%v) still exists", &res.Name) + } + + return nil +} diff --git a/internal/provider/resource_silo_test.go b/internal/provider/resource_silo_test.go index 202ceb7a..d8c6f4fe 100644 --- a/internal/provider/resource_silo_test.go +++ b/internal/provider/resource_silo_test.go @@ -68,7 +68,7 @@ resource "oxide_silo" "{{.BlockName}}" { name = "self-signed-wildcard" description = "Self-signed wildcard certificate for *.sys.r3.oxide-preview.com." cert = tls_self_signed_cert.self-signed.cert_pem - key = tls_private_key.self-signed.private_key_pem + key = tls_private_key.self-signed.private_key_pem service = "external_api" }, ] @@ -128,7 +128,7 @@ resource "oxide_silo" "{{.BlockName}}" { name = "self-signed-wildcard" description = "Self-signed wildcard certificate for *.sys.r3.oxide-preview.com." cert = tls_self_signed_cert.self-signed.cert_pem - key = tls_private_key.self-signed.private_key_pem + key = tls_private_key.self-signed.private_key_pem service = "external_api" }, ]