diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d227ede..691c29c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ FEATURES: * Add support to `enable_templating` in `vault_pki_secret_backend_config_urls` ([#2147](https://github.com/hashicorp/terraform-provider-vault/pull/2147)). * Add support for `skip_import_rotation` and `skip_static_role_import_rotation` in `ldap_secret_backend_static_role` and `ldap_secret_backend` respectively. Requires Vault 1.16+ ([#2128](https://github.com/hashicorp/terraform-provider-vault/pull/2128)). * Improve logging to track full API exchanges between the provider and Vault ([#2139](https://github.com/hashicorp/terraform-provider-vault/pull/2139)) +* Add new resource `vault_config_ui_custom_message`. Requires Vault 1.16+ Enterprise: ([#2154](https://github.com/hashicorp/terraform-provider-vault/pull/2154)). IMPROVEMENTS: * Improve performance of READ operations across many resources: ([#2145](https://github.com/hashicorp/terraform-provider-vault/pull/2145)), ([#2152](https://github.com/hashicorp/terraform-provider-vault/pull/2152)) diff --git a/internal/consts/consts.go b/internal/consts/consts.go index bf1c95c3b..c9996a9c7 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -395,6 +395,12 @@ const ( FieldInstallationID = "installation_id" FieldAppID = "app_id" FieldAIAPath = "aia_path" + FieldTitle = "title" + FieldMessageBase64 = "message_base64" + FieldAuthenticated = "authenticated" + FieldStartTime = "start_time" + FieldEndTime = "end_time" + FieldLink = "link" /* common environment variables diff --git a/vault/provider.go b/vault/provider.go index b14d6d729..e97979df8 100644 --- a/vault/provider.go +++ b/vault/provider.go @@ -765,6 +765,10 @@ var ( Resource: UpdateSchemaResource(secretsSyncAssociationResource()), PathInventory: []string{"/sys/sync/destinations/{type}/{name}/associations/set"}, }, + "vault_config_ui_custom_message": { + Resource: UpdateSchemaResource(configUICustomMessageResource()), + PathInventory: []string{"/sys/config/ui/custom-messages"}, + }, } ) diff --git a/vault/resource_config_ui_custom_message.go b/vault/resource_config_ui_custom_message.go new file mode 100644 index 000000000..91d6ff985 --- /dev/null +++ b/vault/resource_config_ui_custom_message.go @@ -0,0 +1,283 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vault + +import ( + "context" + "log" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/provider" + "github.com/hashicorp/terraform-provider-vault/util" + "github.com/hashicorp/vault/api" +) + +func configUICustomMessageResource() *schema.Resource { + return &schema.Resource{ + CreateContext: provider.MountCreateContextWrapper(configUICustomMessageCreate, provider.VaultVersion116), + ReadContext: configUICustomMessageRead, + UpdateContext: configUICustomMessageUpdate, + DeleteContext: configUICustomMessageDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + consts.FieldID: { + Type: schema.TypeString, + Computed: true, + Description: "The unique ID for the custom message", + }, + consts.FieldTitle: { + Type: schema.TypeString, + Required: true, + Description: "The title of the custom message", + }, + consts.FieldMessageBase64: { + Type: schema.TypeString, + Required: true, + Description: "The base64-encoded content of the custom message", + }, + consts.FieldAuthenticated: { + Type: schema.TypeBool, + Optional: true, + + Default: true, + Description: "A flag indicating whether the custom message is displayed pre-login (false) or post-login (true)", + }, + consts.FieldType: { + Type: schema.TypeString, + Optional: true, + Default: "banner", + ValidateDiagFunc: func(value interface{}, _ cty.Path) diag.Diagnostics { + stringValue := value.(string) + switch { + case stringValue != "banner" && stringValue != "modal": + return diag.Diagnostics{diag.Diagnostic{ + Severity: diag.Error, + Summary: "invalid value for \"type\" argument", + Detail: "The \"type\" argument can only be set to \"banner\" or \"modal\".", + }} + } + + return nil + }, + Description: "The display type of custom message. Allowed values are banner and modal", + }, + consts.FieldStartTime: { + Type: schema.TypeString, + Required: true, + Description: "The starting time of the active period of the custom message", + }, + consts.FieldEndTime: { + Type: schema.TypeString, + Optional: true, + Description: "The ending time of the active period of the custom message. Can be omitted for non-expiring message", + }, + consts.FieldLink: { + Type: schema.TypeSet, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "title": { + Type: schema.TypeString, + Required: true, + Description: "The title of the hyperlink", + }, + "href": { + Type: schema.TypeString, + Required: true, + Description: "The URL of the hyperlink", + }, + }, + }, + Description: "A block containing a hyperlink associated with the custom message", + }, + consts.FieldOptions: { + Type: schema.TypeMap, + Optional: true, + Description: "A map containing additional options for the custom message", + }, + }, + } +} + +func configUICustomMessageCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + if !provider.IsEnterpriseSupported(meta) { + return diag.Errorf("config_ui_custom_message is not supported by this version of vault") + } + + client, e := provider.GetClient(d, meta) + if e != nil { + return diag.FromErr(e) + } + + secret, e := client.Sys().CreateUICustomMessageWithContext(ctx, buildUICustomMessageRequest(d)) + if e != nil { + return diag.FromErr(e) + } + + if secret == nil || secret.Data == nil { + return diag.Errorf(`response from Vault server is empty`) + } + + id, ok := secret.Data[consts.FieldID] + if !ok { + return diag.Errorf("error creating custom message: %s", secret.Data["error"]) + } + + d.SetId(id.(string)) + + return configUICustomMessageRead(ctx, d, meta) +} + +func configUICustomMessageRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client, e := provider.GetClient(d, meta) + if e != nil { + return diag.FromErr(e) + } + + id := d.Id() + + log.Printf("[DEBUG] Reading custom message %q", id) + secret, e := client.Sys().ReadUICustomMessage(id) + if e != nil { + if util.Is404(e) { + log.Printf("[DEBUG] custom message %q not found, removing from state", id) + d.SetId("") + return nil + } + return diag.FromErr(e) + } + + if secret == nil || secret.Data == nil { + log.Printf("[DEBUG] response from Vault server is empty for %q, removing from state", id) + d.SetId("") + return nil + } + + secretData := secret.Data + + if _, ok := secretData["error"]; ok { + errorList := secretData["error"].([]string) + return diag.Errorf("errors received from Vault server: %s", errorList) + } + + var endTimeValue string + if v, ok := secretData[consts.FieldEndTime]; ok { + if v != nil { + endTimeValue = v.(string) + } + } + + var linkValue []map[string]interface{} + + if v, ok := secretData[consts.FieldLink]; ok { + if v != nil { + linkMap := v.(map[string]any) + + if len(linkMap) > 1 { + return diag.Errorf(`invalid link specification: only a single link can be specified`) + } + + for k, v := range linkMap { + stringV, ok := v.(string) + if !ok { + return diag.Errorf("invalid href value in link specification: %v", v) + } + if len(k) > 0 && len(stringV) > 0 { + linkValue = append(linkValue, map[string]interface{}{ + "title": k, + "href": stringV, + }, + ) + } + break + } + } + } + + d.Set(consts.FieldTitle, secretData[consts.FieldTitle]) + d.Set(consts.FieldMessageBase64, secretData["message"]) + d.Set(consts.FieldAuthenticated, secretData[consts.FieldAuthenticated]) + d.Set(consts.FieldType, secretData[consts.FieldType]) + d.Set(consts.FieldStartTime, secretData[consts.FieldStartTime]) + d.Set(consts.FieldEndTime, endTimeValue) + + if linkValue != nil { + d.Set(consts.FieldLink, linkValue) + } + + d.Set(consts.FieldOptions, secretData[consts.FieldOptions]) + + log.Printf("[DEBUG] Read custom message %q", id) + return nil +} + +func configUICustomMessageUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client, e := provider.GetClient(d, meta) + if e != nil { + return diag.FromErr(e) + } + + id := d.Id() + + if d.HasChanges(consts.FieldTitle, consts.FieldMessageBase64, consts.FieldAuthenticated, consts.FieldType, consts.FieldStartTime, consts.FieldEndTime, consts.FieldOptions, consts.FieldLink) { + log.Printf("[DEBUG] Updating custom message %q", id) + e = client.Sys().UpdateUICustomMessageWithContext(ctx, id, buildUICustomMessageRequest(d)) + if e != nil { + return diag.FromErr(e) + } + } + + log.Printf("[DEBUG] Updated custom message %q", id) + return configUICustomMessageRead(ctx, d, meta) +} + +func configUICustomMessageDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client, e := provider.GetClient(d, meta) + if e != nil { + return diag.FromErr(e) + } + + id := d.Id() + + log.Printf("[DEBUG] Deleting custom message %q", id) + e = client.Sys().DeleteUICustomMessageWithContext(ctx, id) + if e != nil { + return diag.Errorf("error deleting custom message %q: %s", id, e) + } + + log.Printf("[DEBUG] Deleted custom message %q", id) + return nil +} + +func buildUICustomMessageRequest(d *schema.ResourceData) api.UICustomMessageRequest { + request := api.UICustomMessageRequest{ + Title: d.Get(consts.FieldTitle).(string), + Message: d.Get(consts.FieldMessageBase64).(string), + Authenticated: d.Get(consts.FieldAuthenticated).(bool), + Type: d.Get(consts.FieldType).(string), + StartTime: d.Get(consts.FieldStartTime).(string), + EndTime: d.Get(consts.FieldEndTime).(string), + Options: d.Get(consts.FieldOptions).(map[string]interface{}), + } + + linkValue := d.Get(consts.FieldLink).(*schema.Set) + if linkValue.Len() == 1 { + slice := linkValue.List() + + m := slice[0].(map[string]interface{}) + linkTitle := m["title"].(string) + linkHref := m["href"].(string) + + request.WithLink(linkTitle, linkHref) + } + + return request +} diff --git a/vault/resource_config_ui_custom_message_test.go b/vault/resource_config_ui_custom_message_test.go new file mode 100644 index 000000000..8e19fcd22 --- /dev/null +++ b/vault/resource_config_ui_custom_message_test.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vault + +import ( + "encoding/base64" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/provider" + "github.com/hashicorp/terraform-provider-vault/testutil" +) + +var ( + messageBase64 = base64.StdEncoding.EncodeToString([]byte("Vault will be unavailable for maintenance on 2024-02-28 from 05:00Z to 07:00Z")) +) + +func testConfigUICustomMessageConfig(isUpdate bool) string { + if !isUpdate { + return fmt.Sprintf(` + resource "vault_config_ui_custom_message" "test" { + title = "Maintenance Adviosry" + message_base64 = "%s" + start_time = "2024-02-01T00:00:00Z" + }`, messageBase64) // There's an intentional typo in the title + } else { + return fmt.Sprintf(` + resource "vault_config_ui_custom_message" "test" { + title = "Maintenance Advisory" + message_base64 = "%s" + start_time = "2024-02-01T00:00:00Z" + end_time = "2024-02-27T23:59:59Z" + type = "modal" + authenticated = false + link { + title = "Learn more" + href = "https://www.hashicorp.com" + } + options = { + "background-color" = "red" + } + }`, messageBase64) // That intentional typo in the title is fixed here + } +} + +func TestAccConfigUICustomMessage(t *testing.T) { + resourceName := "vault_config_ui_custom_message.test" + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + PreCheck: func() { + testutil.TestAccPreCheck(t) + SkipIfAPIVersionLT(t, testProvider.Meta(), provider.VaultVersion116) + }, + Steps: []resource.TestStep{ + { + Config: testConfigUICustomMessageConfig(false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, consts.FieldTitle, "Maintenance Adviosry"), // Checking that intentional typo + resource.TestCheckResourceAttr(resourceName, consts.FieldMessageBase64, messageBase64), + resource.TestCheckResourceAttr(resourceName, consts.FieldStartTime, "2024-02-01T00:00:00Z"), + resource.TestCheckResourceAttr(resourceName, consts.FieldEndTime, ""), + resource.TestCheckResourceAttr(resourceName, consts.FieldType, "banner"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAuthenticated, "true"), + ), + }, + { + Config: testConfigUICustomMessageConfig(true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, consts.FieldTitle, "Maintenance Advisory"), + resource.TestCheckResourceAttr(resourceName, consts.FieldMessageBase64, messageBase64), + resource.TestCheckResourceAttr(resourceName, consts.FieldStartTime, "2024-02-01T00:00:00Z"), + resource.TestCheckResourceAttr(resourceName, consts.FieldEndTime, "2024-02-27T23:59:59Z"), + resource.TestCheckResourceAttr(resourceName, consts.FieldType, "modal"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAuthenticated, "false"), + resource.TestCheckResourceAttr(resourceName, fmt.Sprintf("%s.0.title", consts.FieldLink), "Learn more"), + resource.TestCheckResourceAttr(resourceName, fmt.Sprintf("%s.0.href", consts.FieldLink), "https://www.hashicorp.com"), + resource.TestCheckResourceAttr(resourceName, fmt.Sprintf("%s.background-color", consts.FieldOptions), "red"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + +} diff --git a/website/docs/r/config_ui_custom_messages.html.md b/website/docs/r/config_ui_custom_messages.html.md new file mode 100644 index 000000000..85dd0a1d7 --- /dev/null +++ b/website/docs/r/config_ui_custom_messages.html.md @@ -0,0 +1,69 @@ +--- +layout: "vault" +page_title: "Vault: vault_config_ui_custom_message resource" +sidebar_current: "docs-vault-resource-config-ui-custom-message" +description: |- + Manages a UI custom message in Vault. +--- + +# vault\_config\_ui\_custom\_message + +Manages a UI custom message in Vault. Custom messages are displayed in the Vault UI either on the login page or immediately after succesfully logging in. + +## Example Usage + +```hcl +resource "vault_config_ui_custom_message" "maintenance" { + title = "Upcoming maintenance" + message = base64encode("Vault will be offline for planned maintenance on February 1st, 2024 from 05:00Z to 08:00Z") + type = "banner" + authenticated = true + start_time = "2024-01-01T00:00:00.000Z" + end_time = "2024-02-01T05:00:00.000Z" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `namespace` - (Optional) The namespace to provision the resource in. + The value should not contain leading or trailing forward slashes. + The `namespace` is always relative to the provider's configured [namespace](/docs/providers/vault#namespace). + *Available only for Vault Enterprise*. + +* `title` - (Required) The title of the custom message to create. + +* `message` - (Required) The base64-encoded content of the custom message. + +* `start_time` - (Required) The time when the custom message begins to be active. This value can be set to a future time, but cannot + occur on or after the `end_time` value. + +* `authenticated` - (Optional) The value `true` if the custom message is displayed after logins are completed or `false` if they are + displayed during the login in the Vault UI. The default value is `true`. + +* `type` - (Optional) The presentation type of the custom message. Must be one of the following values: `banner` or `modal`. + +* `end_time` - (Optional) The time when the custom message expires. If this value is not specified, the custom message never expires. + +* `link` - (Optional) A hyperlink to be included with the message. [See below for more details](#link). + +* `options` - (Optional) A map of additional options that can be set on the custom message. + +### Link + +* `title` - (Required) The hyperlink title that is displayed in the custom message. + +* `href` - (Required) The URL set in the hyperlink's href attribute. + +## Attributes Reference + +No additional attributes are exported by this resource. + +## Import + +Custom messages can be imported using their `id` e.g. + +``` +$ terraform import vault_config_ui_custom_message.maintenance df773ef1-2794-45d3-9e25-bcccffe4dbde +``` diff --git a/website/vault.erb b/website/vault.erb index f05a2d6db..2805ff5a8 100644 --- a/website/vault.erb +++ b/website/vault.erb @@ -257,6 +257,10 @@ vault_cert_auth_backend_role + > + vault_config_ui_custom_message + + > vault_consul_secret_backend