Define a configuration for the secrets engine
In the Define a backend for the secrets engine tutorial, you configured the secrets engine to retrieve attributes and create a new client to access your target API.
In this tutorial, you will create a configuration schema for your
secrets engine. The configuration will include attributes passed
through the config
path of the secrets engine.
To do this, you will:
- Set up your development environment.
You will clone the HashiCups secrets engine repository. This contains many of the interfaces and objects you need to create a secrets engine. - Define the fields for the secrets engine's configuration.
You will define a set of fields that a Vault operator passes to configure the secrets engine. - Implement read for the secrets engine's configuration.
You will implement a method to handle reading the configuration from the secrets engine backend. - Implement create and update for the secrets engine's configuration.
You will implement a method to handle writing the configuration to the secrets engine backend. - Implement delete for the secrets engine's configuration.
You will implement a method to handle deletion of the configuration from the secrets engine backend. - Add the configuration path to the backend.
You will update the backend to add a new API path for the configuration. - Explore unit tests that verify the configuration path.
You will examine unit tests that check that Vault can create, read, update, and delete the secrets engine configuration.
Prerequisites
- Golang 1.16+ installed and configured.
- Vault 1.8+ CLI installed locally.
Note
Complete the tutorial to define the backend for the secrets engine.
Set up your development environment
Clone the learn-vault-plugin-secrets-hashicups repository.
$ git clone https://github.com/hashicorp-education/learn-vault-plugin-secrets-hashicups
Change into the repository directory.
$ cd vault-plugin-secrets-hashicups
Note
If you are stuck in this tutorial, refer to the
vault-plugin-secrets-hashicups/solution
directory.
Define the fields for the secrets engine's configuration
Open path_config.go
. The file contains all of the objects
and methods related to setting up the config
path for
the secrets engine.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
The file should already include implementation for the following:
configStoragePath
: defines theconfig
path for the secrets engine's configuration.hashiCupsConfig
: defines a configuration object with username, password, and URL to the target API (HashiCups).getConfig
: passes the context and storage path for Vault to store thehashiCupsConfig
into theconfig
path for the secrets engine.
However, the pathConfig
method returns an object with empty fields.
These attributes defined framework.Path
extend the Vault API for
the secrets engine's config
path.
Replace the pathConfig
method in the scaffold.
path_config.go
func pathConfig(b *hashiCupsBackend) *framework.Path { return &framework.Path{ Pattern: "config", Fields: map[string]*framework.FieldSchema{ "username": { Type: framework.TypeString, Description: "The username to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Username", Sensitive: false, }, }, "password": { Type: framework.TypeString, Description: "The user's password to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Password", Sensitive: true, }, }, "url": { Type: framework.TypeString, Description: "The URL for the HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "URL", Sensitive: false, }, }, }, Operations: map[logical.Operation]framework.OperationHandler{}, ExistenceCheck: b.pathConfigExistenceCheck, HelpSynopsis: pathConfigHelpSynopsis, HelpDescription: pathConfigHelpDescription, }}
You must define the Fields
that extend the Vault config
endpoint. They must
match the attributes in hashiCupsConfig
. Each field attribute must include the following:
Type
: All of the attributes forhashiCupsConfig
useType.String
. You can find a list of types in the Vault SDK documentation.Description
: Purpose of the attributeRequired
: Used for OpenAPI output. Whether or not a Vault operator must define the attribute when configuring the secrets engine. You must enforce it in the handler of your secrets engine, the schema will not enforce it for you!DisplayAttr
: Used for OpenAPI output. Includes name and whether or not it should be output based on the value inSensitive
.
path_config.go
func pathConfig(b *hashiCupsBackend) *framework.Path { return &framework.Path{ Pattern: "config", Fields: map[string]*framework.FieldSchema{ "username": { Type: framework.TypeString, Description: "The username to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Username", Sensitive: false, }, }, "password": { Type: framework.TypeString, Description: "The user's password to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Password", Sensitive: true, }, }, "url": { Type: framework.TypeString, Description: "The URL for the HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "URL", Sensitive: false, }, }, }, Operations: map[logical.Operation]framework.OperationHandler{}, ExistenceCheck: b.pathConfigExistenceCheck, HelpSynopsis: pathConfigHelpSynopsis, HelpDescription: pathConfigHelpDescription, }}
The pathConfig
includes additional attributes for the configuration, such
as ExistenceCheck
. ExistenceCheck
calls the pathConfigExistenceCheck
function,
which verifies whether or not the configuration already exists in Vault.
Note
Adding an ExistenceCheck
will affect your Vault
access control list (ACL) policy
for the secrets engine. When you define the ExistenceCheck
for the configuration,
a Vault operator setting up the secrets engine must have the create
capability
to add the configuration.
path_config.go
func pathConfig(b *hashiCupsBackend) *framework.Path { return &framework.Path{ Pattern: "config", Fields: map[string]*framework.FieldSchema{ "username": { Type: framework.TypeString, Description: "The username to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Username", Sensitive: false, }, }, "password": { Type: framework.TypeString, Description: "The user's password to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Password", Sensitive: true, }, }, "url": { Type: framework.TypeString, Description: "The URL for the HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "URL", Sensitive: false, }, }, }, Operations: map[logical.Operation]framework.OperationHandler{}, ExistenceCheck: b.pathConfigExistenceCheck, HelpSynopsis: pathConfigHelpSynopsis, HelpDescription: pathConfigHelpDescription, }} func (b *hashiCupsBackend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) { out, err := req.Storage.Get(ctx, req.Path) if err != nil { return false, fmt.Errorf("existence check failed: %w", err) } return out != nil, nil}
The path includes attributes for help text, such as HelpSynopsis
and HelpDescription
.
The help text describes the attributes someone needs to configure the secrets engine.
path_config.go
func pathConfig(b *hashiCupsBackend) *framework.Path { return &framework.Path{ Pattern: "config", Fields: map[string]*framework.FieldSchema{ "username": { Type: framework.TypeString, Description: "The username to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Username", Sensitive: false, }, }, "password": { Type: framework.TypeString, Description: "The user's password to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Password", Sensitive: true, }, }, "url": { Type: framework.TypeString, Description: "The URL for the HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "URL", Sensitive: false, }, }, }, Operations: map[logical.Operation]framework.OperationHandler{}, ExistenceCheck: b.pathConfigExistenceCheck, HelpSynopsis: pathConfigHelpSynopsis, HelpDescription: pathConfigHelpDescription, }} const pathConfigHelpSynopsis = `Configure the HashiCups backend.` const pathConfigHelpDescription = `The HashiCups secret backend requires credentials for managingJWTs issued to users working with the products API. You must sign up with a username and password andspecify the HashiCups address for the products APIbefore using this secrets engine backend.`
Implement read for the secrets engine's configuration
Open path_config.go
. The file contains all of the objects
and methods related to setting up the config
path for
the secrets engine.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
The Operations
field in the pathConfig
method starts empty.
You need to add methods to the Operations
field to tell Vault
how to handle creating, reading, updating, and deleting information
at the config
path.
Note
When you build a secrets engine and define its configuration, you need to implement operations to read, create, update and delete information at each API path you define for the secrets engine.
Create a new method named pathConfigRead
in path_config.go
.
The method reads the configuration and outputs non-sensitive fields,
specifically the HashiCups username and URL.
path_config.go
func (b *hashiCupsBackend) pathConfigRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { config, err := getConfig(ctx, req.Storage) if err != nil { return nil, err } return &logical.Response{ Data: map[string]interface{}{ "username": config.Username, "url": config.URL, }, }, nil}
Under the Operations
field, add logical.ReadOperation
to the list
of OperationHandler
and callback to pathConfigRead
. The secrets engine
responds to a read operation from Vault with this method.
path_config.go
func pathConfig(b *hashiCupsBackend) *framework.Path { return &framework.Path{ Pattern: "config", Fields: map[string]*framework.FieldSchema{ "username": { Type: framework.TypeString, Description: "The username to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Username", Sensitive: false, }, }, "password": { Type: framework.TypeString, Description: "The user's password to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Password", Sensitive: true, }, }, "url": { Type: framework.TypeString, Description: "The URL for the HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "URL", Sensitive: false, }, }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathConfigRead, }, }, ExistenceCheck: b.pathConfigExistenceCheck, HelpSynopsis: pathConfigHelpSynopsis, HelpDescription: pathConfigHelpDescription, }}
Implement create and update for the secrets engine's configuration
Open path_config.go
. The file contains all of the objects
and methods related to setting up the config
path for
the secrets engine.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
Create a new method named pathConfigWrite
in path_config.go
.
The method accounts for specific logic, including:
- If the configuration does not exist and you need to update it, the handler throws an error.
- Verify you passed a username, URL, and password for the target API
to the configuration using the
GetOk
method. You useGetOk
to enforce required attributes during aCreateOperation
. - Write the new or updated configuration using
Storage.Put
. - Reset the configuration so Vault picks up the new configuration.
path_config.go
func (b *hashiCupsBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { config, err := getConfig(ctx, req.Storage) if err != nil { return nil, err } createOperation := (req.Operation == logical.CreateOperation) if config == nil { if !createOperation { return nil, errors.New("config not found during update operation") } config = new(hashiCupsConfig) } if username, ok := data.GetOk("username"); ok { config.Username = username.(string) } else if !ok && createOperation { return nil, fmt.Errorf("missing username in configuration") } if url, ok := data.GetOk("url"); ok { config.URL = url.(string) } else if !ok && createOperation { return nil, fmt.Errorf("missing url in configuration") } if password, ok := data.GetOk("password"); ok { config.Password = password.(string) } else if !ok && createOperation { return nil, fmt.Errorf("missing password in configuration") } entry, err := logical.StorageEntryJSON(configStoragePath, config) if err != nil { return nil, err } if err := req.Storage.Put(ctx, entry); err != nil { return nil, err } b.reset() return nil, nil}
Under the Operations
field, add logical.CreateOperation
and
logical.UpdateOperation
to the list of OperationHandler
.
Both operations should callback to pathConfigWrite
.
path_config.go
func pathConfig(b *hashiCupsBackend) *framework.Path { return &framework.Path{ Pattern: "config", Fields: map[string]*framework.FieldSchema{ "username": { Type: framework.TypeString, Description: "The username to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Username", Sensitive: false, }, }, "password": { Type: framework.TypeString, Description: "The user's password to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Password", Sensitive: true, }, }, "url": { Type: framework.TypeString, Description: "The URL for the HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "URL", Sensitive: false, }, }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathConfigRead, }, logical.CreateOperation: &framework.PathOperation{ Callback: b.pathConfigWrite, }, logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathConfigWrite, }, }, ExistenceCheck: b.pathConfigExistenceCheck, HelpSynopsis: pathConfigHelpSynopsis, HelpDescription: pathConfigHelpDescription, }}
Note
For most secrets engines, you can consolidate handling for create and update of fields. Ensure that the method runs idempotently (repeatedly running the function does not change the fields, unless you make changes).
Implement delete for the secrets engine's configuration
Open path_config.go
. The file contains all of the objects
and methods related to setting up the config
path for
the secrets engine.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
Create a new method named pathConfigDelete
in path_config.go
.
The method deletes the configuration from the secrets engine backend
and resets the secrets engine.
path_config.go
func (b *hashiCupsBackend) pathConfigDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { err := req.Storage.Delete(ctx, configStoragePath) if err == nil { b.reset() } return nil, err}
Under the Operations
field, add logical.DeleteOperation
to the list of OperationHandler
. It should callback to pathConfigDelete
.
path_config.go
func pathConfig(b *hashiCupsBackend) *framework.Path { return &framework.Path{ Pattern: "config", Fields: map[string]*framework.FieldSchema{ "username": { Type: framework.TypeString, Description: "The username to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Username", Sensitive: false, }, }, "password": { Type: framework.TypeString, Description: "The user's password to access HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "Password", Sensitive: true, }, }, "url": { Type: framework.TypeString, Description: "The URL for the HashiCups Product API", Required: true, DisplayAttrs: &framework.DisplayAttributes{ Name: "URL", Sensitive: false, }, }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathConfigRead, }, logical.CreateOperation: &framework.PathOperation{ Callback: b.pathConfigWrite, }, logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathConfigWrite, }, logical.DeleteOperation: &framework.PathOperation{ Callback: b.pathConfigDelete, }, }, ExistenceCheck: b.pathConfigExistenceCheck, HelpSynopsis: pathConfigHelpSynopsis, HelpDescription: pathConfigHelpDescription, }}
Add the configuration path to the backend
For each API path you extend on the secrets engine, you must add it to the secrets engine backend.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
Open backend.go
and replace backend
to add pathConfig
to the list
of valid paths for the backend.
backend.go
func backend() *hashiCupsBackend { var b = hashiCupsBackend{} b.Backend = &framework.Backend{ Help: strings.TrimSpace(backendHelp), PathsSpecial: &logical.Paths{ LocalStorage: []string{}, SealWrapStorage: []string{ "config", "role/*", }, }, Paths: framework.PathAppend( []*framework.Path{ pathConfig(&b), }, ), Secrets: []*framework.Secret{}, BackendType: logical.TypeLogical, Invalidate: b.invalidate, } return &b}
Note
If you do not add your path to the backend object,
you will get an error of unsupported path
in your tests
and compiled plugin.
Explore unit tests that verify the configuration path
The Vault Plugin SDK includes a testing framework for unit and acceptance tests.
- Unit tests: Use mocks to verify the functionality of the secrets engine
- Acceptance tests: Require a Vault instance, an active target API endpoint, and binary for the secrets engine.
You can write a set of unit tests to pass in fields and mock the Vault backend. The tests verify secrets engine creates, reads, updates, and deletes the configuration.
Open backend_test.go
and examine getTestBackend
. The method
mocks a backend object using the Vault Plugin SDK. It references the
interfaces in the HashiCups backend Factory
.
backend_test.go
func getTestBackend(tb testing.TB) (*hashiCupsBackend, logical.Storage) { tb.Helper() config := logical.TestBackendConfig() config.StorageView = new(logical.InmemStorage) config.Logger = hclog.NewNullLogger() config.System = logical.TestSystemView() b, err := Factory(context.Background(), config) if err != nil { tb.Fatal(err) } return b.(*hashiCupsBackend), config.StorageView}
Note
You can reuse getTestBackend
for your own secrets engine. Return
your secrets engine's backend object instead of hashiCupsBackend
.
Open path_config_test.go
. The file includes a set of
constants that you will pass as configuration fields for the config
path.
The HashiCups configuration requires username, password, and URL. The unit
tests will not issue requests to the API endpoint.
path_config_test.go
const ( username = "vault-plugin-testing" password = "Testing!123" url = "http://localhost:19090")
Examine the method TestConfig
. It creates the mock backend
with getTestBackend
and runs a series of tests creating,
reading, updating, and deleting the configuration using the
constants.
path_config_test.go
func TestConfig(t *testing.T) { b, reqStorage := getTestBackend(t) t.Run("Test Configuration", func(t *testing.T) { err := testConfigCreate(t, b, reqStorage, map[string]interface{}{ "username": username, "password": password, "url": url, }) assert.NoError(t, err) err = testConfigRead(t, b, reqStorage, map[string]interface{}{ "username": username, "url": url, }) assert.NoError(t, err) err = testConfigUpdate(t, b, reqStorage, map[string]interface{}{ "username": username, "url": "http://hashicups:19090", }) assert.NoError(t, err) err = testConfigRead(t, b, reqStorage, map[string]interface{}{ "username": username, "url": "http://hashicups:19090", }) assert.NoError(t, err) err = testConfigDelete(t, b, reqStorage) assert.NoError(t, err) })}
Examine testConfigCreate
as an example. It calls the
mock backend with a logical.CreateOperation
at the path,
config
. The data includes the configuration fields defined
as constants, such as username, password, and URL.
path_config_test.go
func testConfigCreate(t *testing.T, b logical.Backend, s logical.Storage, d map[string]interface{}) error { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.CreateOperation, Path: configStoragePath, Data: d, Storage: s, }) if err != nil { return err } if resp != nil && resp.IsError() { return resp.Error() } return nil}
TestConfig
runs the tests sequentially and passes the same
storage object between tests. You should write your test sequence as follows:
- Create the configuration.
- Read the configuration to test if the create succeeded.
- Update the configuration.
- Read the configuration to test if the update succeeded.
- Delete the configuration.
- Check for errors.
Open a terminal and make sure your working directory uses
the plugins/vault-plugin-secrets-hashicups
.
$ pwd ${HOME}/hashicorp/vault-guides/plugins/vault-plugin-secrets-hashicups
Run the configuration path tests in your terminal. The tests should pass.
$ go test -v -run TestConfig === RUN TestConfig=== RUN TestConfig/Test_Configuration--- PASS: TestConfig (0.00s) --- PASS: TestConfig/Test_Configuration (0.00s)PASSok github.com/hashicorp/vault-guides/plugins/vault-plugin-secrets-hashicups 0.186s
Next steps
Congratulations! You added the config
path to your secrets engine.
If you are stuck in this tutorial, refer to the
plugins/vault-plugin-secrets-hashicups/solution
directory.
- To learn more about Vault plugins, refer to the Vault Plugin System Documentation.
- Define your secrets engine's roles in the next tutorial.